diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.spec.tsx new file mode 100644 index 0000000000..3ed5d12a86 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.spec.tsx @@ -0,0 +1,1162 @@ +import type { LexicalEditor } from 'lexical' +import type { + ContextBlockType, + CurrentBlockType, + ErrorMessageBlockType, + ExternalToolBlockType, + ExternalToolOption, + HistoryBlockType, + LastRunBlockType, + Option, + QueryBlockType, + RequestURLBlockType, + VariableBlockType, + WorkflowVariableBlockType, +} from '../../types' +import type { NodeOutPutVar } from '@/app/components/workflow/types' +import { LexicalComposer } from '@lexical/react/LexicalComposer' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { renderHook } from '@testing-library/react' +import * as React from 'react' +import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types' +import { VarType } from '@/app/components/workflow/types' +import { CustomTextNode } from '../custom-text/node' +import { + useExternalToolOptions, + useOptions, + usePromptOptions, + useVariableOptions, +} from './hooks' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Minimal LexicalComposer wrapper required by useLexicalComposerContext(). + * The actual editor nodes registered here are empty – hooks only need the + * context to call dispatchCommand / update. + * + * Note: A new wrapper is created per describe block so each describe block has + * its own isolated Lexical instance. + */ +function makeLexicalWrapper() { + const initialConfig = { + namespace: 'hooks-test', + onError: (err: Error) => { throw err }, + // CustomTextNode must be registered so editor.update() in addOption's onSelect can create it + nodes: [CustomTextNode], + } + return function LexicalWrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) + } +} + +// ─── Factory helpers (typed, no `any` / `never`) ───────────────────────────── + +function makeContextBlock(overrides: Partial = {}): ContextBlockType { + return { show: true, selectable: true, ...overrides } +} + +function makeQueryBlock(overrides: Partial = {}): QueryBlockType { + return { show: true, selectable: true, ...overrides } +} + +function makeHistoryBlock(overrides: Partial = {}): HistoryBlockType { + return { show: true, selectable: true, ...overrides } +} + +function makeRequestURLBlock(overrides: Partial = {}): RequestURLBlockType { + return { show: true, selectable: true, ...overrides } +} + +function makeVariableBlock(variables: Option[] = [], overrides: Partial = {}): VariableBlockType { + return { show: true, variables, ...overrides } +} + +function makeExternalToolBlock( + overrides: Partial = {}, + tools: ExternalToolOption[] = [], +): ExternalToolBlockType { + return { show: true, externalTools: tools, ...overrides } +} + +function makeWorkflowVariableBlock( + variables: NodeOutPutVar[] = [], + overrides: Partial = {}, +): WorkflowVariableBlockType { + return { show: true, variables, ...overrides } +} + +function makeVar(variable: string, type: VarType = VarType.string) { + return { variable, type } +} + +function makeNodeOutPutVar(nodeId: string, title: string, vars: ReturnType[] = []): NodeOutPutVar { + return { nodeId, title, vars } +} + +// ─── Shared mock render-prop arguments ─────────────────────────────────────── +// These are the props passed to renderMenuOption() in option objects +const renderProps = { + isSelected: false, + onSelect: vi.fn(), + onSetHighlight: vi.fn(), + queryString: null as string | null, +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// usePromptOptions +// ═══════════════════════════════════════════════════════════════════════════════ +describe('usePromptOptions', () => { + // Ensure clean spy state before every test + beforeEach(() => { + vi.clearAllMocks() + }) + + const wrapper = makeLexicalWrapper() + + /** + * When all blocks are undefined (not passed) the hook should return an empty array. + * This is the "no blocks configured" base case. + */ + describe('when no blocks are provided', () => { + it('should return an empty array', () => { + const { result } = renderHook(() => usePromptOptions(), { wrapper }) + expect(result.current).toHaveLength(0) + }) + }) + + /** + * contextBlock has two states: show=false (hidden) and show=true (visible). + * When show=false the option must NOT be included. + */ + describe('contextBlock', () => { + it('should NOT include context option when show is false', () => { + const { result } = renderHook( + () => usePromptOptions(makeContextBlock({ show: false })), + { wrapper }, + ) + expect(result.current).toHaveLength(0) + }) + + it('should include context option when show is true', () => { + const { result } = renderHook( + () => usePromptOptions(makeContextBlock({ show: true })), + { wrapper }, + ) + expect(result.current).toHaveLength(1) + expect(result.current[0].group).toBe('prompt context') + }) + + it('should render the context PromptMenuItem without crashing', () => { + const { result } = renderHook( + () => usePromptOptions(makeContextBlock()), + { wrapper }, + ) + // renderMenuOption returns a React element – just verify it's truthy + const el = result.current[0].renderMenuOption(renderProps) + expect(el).toBeTruthy() + }) + + it('should dispatch INSERT_CONTEXT_BLOCK_COMMAND when selectable and onSelectMenuOption is called', () => { + // Capture the editor from within the same renderHook callback so we can spy on it + let capturedEditor: LexicalEditor | null = null + + const { result } = renderHook( + () => { + const [editor] = useLexicalComposerContext() + capturedEditor = editor + return usePromptOptions(makeContextBlock({ selectable: true })) + }, + { wrapper }, + ) + + const spy = vi.spyOn(capturedEditor!, 'dispatchCommand') + result.current[0].onSelectMenuOption() + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should NOT dispatch any command when selectable is false', () => { + let capturedEditor: LexicalEditor | null = null + + const { result } = renderHook( + () => { + const [editor] = useLexicalComposerContext() + capturedEditor = editor + return usePromptOptions(makeContextBlock({ selectable: false })) + }, + { wrapper }, + ) + + const spy = vi.spyOn(capturedEditor!, 'dispatchCommand') + result.current[0].onSelectMenuOption() + expect(spy).not.toHaveBeenCalled() + }) + }) + + /** + * queryBlock mirrors contextBlock: hidden when show=false, visible and dispatching when show=true. + */ + describe('queryBlock', () => { + it('should NOT include query option when show is false', () => { + const { result } = renderHook( + () => usePromptOptions(undefined, makeQueryBlock({ show: false })), + { wrapper }, + ) + expect(result.current).toHaveLength(0) + }) + + it('should include query option when show is true', () => { + const { result } = renderHook( + () => usePromptOptions(undefined, makeQueryBlock()), + { wrapper }, + ) + expect(result.current).toHaveLength(1) + expect(result.current[0].group).toBe('prompt query') + }) + + it('should render the query PromptMenuItem without crashing', () => { + const { result } = renderHook( + () => usePromptOptions(undefined, makeQueryBlock()), + { wrapper }, + ) + const el = result.current[0].renderMenuOption(renderProps) + expect(el).toBeTruthy() + }) + + it('should dispatch INSERT_QUERY_BLOCK_COMMAND when selectable', () => { + let capturedEditor: LexicalEditor | null = null + const { result } = renderHook( + () => { + const [editor] = useLexicalComposerContext() + capturedEditor = editor + return usePromptOptions(undefined, makeQueryBlock({ selectable: true })) + }, + { wrapper }, + ) + const spy = vi.spyOn(capturedEditor!, 'dispatchCommand') + result.current[0].onSelectMenuOption() + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should NOT dispatch command when selectable is false', () => { + let capturedEditor: LexicalEditor | null = null + const { result } = renderHook( + () => { + const [editor] = useLexicalComposerContext() + capturedEditor = editor + return usePromptOptions(undefined, makeQueryBlock({ selectable: false })) + }, + { wrapper }, + ) + const spy = vi.spyOn(capturedEditor!, 'dispatchCommand') + result.current[0].onSelectMenuOption() + expect(spy).not.toHaveBeenCalled() + }) + }) + + /** + * requestURLBlock – added in third position when show=true. + */ + describe('requestURLBlock', () => { + it('should NOT include request URL option when show is false', () => { + const { result } = renderHook( + () => usePromptOptions(undefined, undefined, undefined, makeRequestURLBlock({ show: false })), + { wrapper }, + ) + expect(result.current).toHaveLength(0) + }) + + it('should include request URL option when show is true', () => { + const { result } = renderHook( + () => usePromptOptions(undefined, undefined, undefined, makeRequestURLBlock()), + { wrapper }, + ) + expect(result.current).toHaveLength(1) + expect(result.current[0].group).toBe('request URL') + }) + + it('should render the requestURL PromptMenuItem without crashing', () => { + const { result } = renderHook( + () => usePromptOptions(undefined, undefined, undefined, makeRequestURLBlock()), + { wrapper }, + ) + const el = result.current[0].renderMenuOption(renderProps) + expect(el).toBeTruthy() + }) + + it('should dispatch INSERT_REQUEST_URL_BLOCK_COMMAND when selectable', () => { + let capturedEditor: LexicalEditor | null = null + const { result } = renderHook( + () => { + const [editor] = useLexicalComposerContext() + capturedEditor = editor + return usePromptOptions(undefined, undefined, undefined, makeRequestURLBlock({ selectable: true })) + }, + { wrapper }, + ) + const spy = vi.spyOn(capturedEditor!, 'dispatchCommand') + result.current[0].onSelectMenuOption() + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should NOT dispatch command when selectable is false', () => { + let capturedEditor: LexicalEditor | null = null + const { result } = renderHook( + () => { + const [editor] = useLexicalComposerContext() + capturedEditor = editor + return usePromptOptions(undefined, undefined, undefined, makeRequestURLBlock({ selectable: false })) + }, + { wrapper }, + ) + const spy = vi.spyOn(capturedEditor!, 'dispatchCommand') + result.current[0].onSelectMenuOption() + expect(spy).not.toHaveBeenCalled() + }) + }) + + /** + * historyBlock – added last when show=true. + */ + describe('historyBlock', () => { + it('should NOT include history option when show is false', () => { + const { result } = renderHook( + () => usePromptOptions(undefined, undefined, makeHistoryBlock({ show: false })), + { wrapper }, + ) + expect(result.current).toHaveLength(0) + }) + + it('should include history option when show is true', () => { + const { result } = renderHook( + () => usePromptOptions(undefined, undefined, makeHistoryBlock()), + { wrapper }, + ) + expect(result.current).toHaveLength(1) + expect(result.current[0].group).toBe('prompt history') + }) + + it('should render the history PromptMenuItem without crashing', () => { + const { result } = renderHook( + () => usePromptOptions(undefined, undefined, makeHistoryBlock()), + { wrapper }, + ) + const el = result.current[0].renderMenuOption(renderProps) + expect(el).toBeTruthy() + }) + + it('should dispatch INSERT_HISTORY_BLOCK_COMMAND when selectable', () => { + let capturedEditor: LexicalEditor | null = null + const { result } = renderHook( + () => { + const [editor] = useLexicalComposerContext() + capturedEditor = editor + return usePromptOptions(undefined, undefined, makeHistoryBlock({ selectable: true })) + }, + { wrapper }, + ) + const spy = vi.spyOn(capturedEditor!, 'dispatchCommand') + result.current[0].onSelectMenuOption() + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should NOT dispatch command when selectable is false', () => { + let capturedEditor: LexicalEditor | null = null + const { result } = renderHook( + () => { + const [editor] = useLexicalComposerContext() + capturedEditor = editor + return usePromptOptions(undefined, undefined, makeHistoryBlock({ selectable: false })) + }, + { wrapper }, + ) + const spy = vi.spyOn(capturedEditor!, 'dispatchCommand') + result.current[0].onSelectMenuOption() + expect(spy).not.toHaveBeenCalled() + }) + }) + + /** + * All four blocks shown simultaneously – verify all four options are produced + * in the correct order: context → query → requestURL → history. + * (requestURL is pushed after query but BEFORE history because the source pushes + * requestURLBlock before historyBlock.) + */ + describe('all blocks visible', () => { + it('should return all four options in correct order', () => { + const { result } = renderHook( + () => usePromptOptions( + makeContextBlock(), + makeQueryBlock(), + makeHistoryBlock(), + makeRequestURLBlock(), + ), + { wrapper }, + ) + expect(result.current).toHaveLength(4) + expect(result.current[0].group).toBe('prompt context') + expect(result.current[1].group).toBe('prompt query') + // requestURL is pushed 3rd – before historyBlock + expect(result.current[2].group).toBe('request URL') + expect(result.current[3].group).toBe('prompt history') + }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// useVariableOptions +// ═══════════════════════════════════════════════════════════════════════════════ +describe('useVariableOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const wrapper = makeLexicalWrapper() + + /** + * Show=false edge case: the hook must return [] even when variables are present. + */ + describe('when variableBlock.show is false', () => { + it('should return an empty array', () => { + const { result } = renderHook( + () => useVariableOptions(makeVariableBlock([{ value: 'foo', name: 'foo' }], { show: false })), + { wrapper }, + ) + expect(result.current).toHaveLength(0) + }) + }) + + /** + * Undefined variableBlock – hook should return []. + */ + describe('when variableBlock is undefined', () => { + it('should return an empty array', () => { + const { result } = renderHook( + () => useVariableOptions(undefined), + { wrapper }, + ) + expect(result.current).toHaveLength(0) + }) + }) + + /** + * variableBlock.variables is undefined while show=true – only addOption is returned + * because the inner `options` memo short-circuits to [] when `variableBlock.variables` + * is falsy, and the final memo includes addOption when show=true. + */ + describe('when variableBlock.variables is undefined', () => { + it('should return only the addOption', () => { + const { result } = renderHook( + () => useVariableOptions({ show: true, variables: undefined }), + { wrapper }, + ) + expect(result.current).toHaveLength(1) + expect(result.current[0].group).toBe('prompt variable') + }) + }) + + /** + * No queryString – all variables are returned plus the addOption. + */ + describe('with variables and no queryString', () => { + it('should return all variables + addOption', () => { + const vars: Option[] = [ + { value: 'alpha', name: 'Alpha' }, + { value: 'beta', name: 'Beta' }, + ] + const { result } = renderHook( + () => useVariableOptions(makeVariableBlock(vars)), + { wrapper }, + ) + // 2 variable options + 1 addOption = 3 + expect(result.current).toHaveLength(3) + expect(result.current[0].key).toBe('alpha') + expect(result.current[1].key).toBe('beta') + }) + + it('should render variable VariableMenuItems without crashing', () => { + const vars: Option[] = [{ value: 'myvar', name: 'My Var' }] + const { result } = renderHook( + () => useVariableOptions(makeVariableBlock(vars)), + { wrapper }, + ) + // Pass a queryString so we exercise the highlight splitting code path in VariableMenuItem + const el = result.current[0].renderMenuOption({ ...renderProps, queryString: 'my' }) + expect(el).toBeTruthy() + }) + + it('should dispatch INSERT_VARIABLE_VALUE_BLOCK_COMMAND with correct payload when variable is selected', () => { + let capturedEditor: LexicalEditor | null = null + const vars: Option[] = [{ value: 'myvar', name: 'My Var' }] + const { result } = renderHook( + () => { + const [editor] = useLexicalComposerContext() + capturedEditor = editor + return useVariableOptions(makeVariableBlock(vars)) + }, + { wrapper }, + ) + const spy = vi.spyOn(capturedEditor!, 'dispatchCommand') + result.current[0].onSelectMenuOption() + // The command payload wraps the value in {{ }} + expect(spy).toHaveBeenCalledWith(expect.anything(), '{{myvar}}') + }) + }) + + /** + * queryString filtering: only variable keys that match the regex survive. + */ + describe('with queryString filtering', () => { + it('should filter variables by queryString (case-insensitive)', () => { + const vars: Option[] = [ + { value: 'alpha', name: 'Alpha' }, + { value: 'beta', name: 'Beta' }, + { value: 'ALPHA_UPPER', name: 'ALPHA_UPPER' }, + ] + const { result } = renderHook( + () => useVariableOptions(makeVariableBlock(vars), 'alpha'), + { wrapper }, + ) + // 'alpha' regex (case-insensitive) matches 'alpha' and 'ALPHA_UPPER'; addOption is always appended + expect(result.current).toHaveLength(3) + expect(result.current[0].key).toBe('alpha') + expect(result.current[1].key).toBe('ALPHA_UPPER') + }) + + it('should return only addOption when no variables match the queryString', () => { + const vars: Option[] = [ + { value: 'alpha', name: 'Alpha' }, + { value: 'beta', name: 'Beta' }, + ] + const { result } = renderHook( + () => useVariableOptions(makeVariableBlock(vars), 'zzz'), + { wrapper }, + ) + // No match → filtered options=[] + addOption = 1 + expect(result.current).toHaveLength(1) + }) + }) + + /** + * addOption – calling onSelectMenuOption triggers editor.update() which + * in turn calls $insertNodes with {{ and }} custom text nodes. + * We only verify update() was invoked since the full DOM mutation requires + * a real Lexical document with registered nodes. + */ + describe('addOption (the last element)', () => { + it('should render addOption VariableMenuItem without crashing', () => { + const { result } = renderHook( + () => useVariableOptions(makeVariableBlock([])), + { wrapper }, + ) + const lastOption = result.current[result.current.length - 1] + const el = lastOption.renderMenuOption(renderProps) + expect(el).toBeTruthy() + }) + + it('should call editor.update() when addOption is selected', () => { + let capturedEditor: LexicalEditor | null = null + const { result } = renderHook( + () => { + const [editor] = useLexicalComposerContext() + capturedEditor = editor + return useVariableOptions(makeVariableBlock([])) + }, + { wrapper }, + ) + const spy = vi.spyOn(capturedEditor!, 'update') + const lastOption = result.current[result.current.length - 1] + lastOption.onSelectMenuOption() + expect(spy).toHaveBeenCalledTimes(1) + }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// useExternalToolOptions +// ═══════════════════════════════════════════════════════════════════════════════ +describe('useExternalToolOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const wrapper = makeLexicalWrapper() + + const sampleTool: ExternalToolOption = { + name: 'weather', + variableName: 'weather_tool', + icon: 'cloud', + icon_background: '#fff', + } + + /** + * Show=false: must always return []. + */ + describe('when externalToolBlockType.show is false', () => { + it('should return an empty array', () => { + const { result } = renderHook( + () => useExternalToolOptions(makeExternalToolBlock({ show: false }, [sampleTool])), + { wrapper }, + ) + expect(result.current).toHaveLength(0) + }) + }) + + /** + * Undefined block: return []. + */ + describe('when externalToolBlockType is undefined', () => { + it('should return an empty array', () => { + const { result } = renderHook( + () => useExternalToolOptions(undefined), + { wrapper }, + ) + expect(result.current).toHaveLength(0) + }) + }) + + /** + * externalTools is undefined while show=true – inner options memo returns [] because + * `externalToolBlockType?.externalTools` is falsy. Only addOption is in the result. + */ + describe('when externalTools is undefined', () => { + it('should return only the addOption', () => { + const { result } = renderHook( + () => useExternalToolOptions({ show: true, externalTools: undefined }), + { wrapper }, + ) + expect(result.current).toHaveLength(1) + expect(result.current[0].group).toBe('external tool') + }) + }) + + /** + * Tools with no queryString – all tools + addOption. + */ + describe('with tools and no queryString', () => { + it('should return all tools + addOption', () => { + const tools: ExternalToolOption[] = [ + { name: 'tool-a', variableName: 'tool_a' }, + { name: 'tool-b', variableName: 'tool_b' }, + ] + const { result } = renderHook( + () => useExternalToolOptions(makeExternalToolBlock({}, tools)), + { wrapper }, + ) + expect(result.current).toHaveLength(3) + expect(result.current[0].key).toBe('tool-a') + expect(result.current[1].key).toBe('tool-b') + }) + + it('should render tool VariableMenuItem (with AppIcon and variableName extra element) without crashing', () => { + const { result } = renderHook( + () => useExternalToolOptions(makeExternalToolBlock({}, [sampleTool])), + { wrapper }, + ) + // pass a queryString to also exercise the highlighting code path + const el = result.current[0].renderMenuOption({ ...renderProps, queryString: 'wea' }) + expect(el).toBeTruthy() + }) + + it('should dispatch INSERT_VARIABLE_VALUE_BLOCK_COMMAND with variableName when tool is selected', () => { + let capturedEditor: LexicalEditor | null = null + const { result } = renderHook( + () => { + const [editor] = useLexicalComposerContext() + capturedEditor = editor + return useExternalToolOptions(makeExternalToolBlock({}, [sampleTool])) + }, + { wrapper }, + ) + const spy = vi.spyOn(capturedEditor!, 'dispatchCommand') + result.current[0].onSelectMenuOption() + // variableName is 'weather_tool', wrapped in {{ }} + expect(spy).toHaveBeenCalledWith(expect.anything(), '{{weather_tool}}') + }) + }) + + /** + * queryString filtering – case-insensitive match against the tool's `name` key. + */ + describe('with queryString filtering', () => { + it('should filter tools by queryString (case-insensitive)', () => { + const tools: ExternalToolOption[] = [ + { name: 'WeatherTool', variableName: 'weather' }, + { name: 'SearchTool', variableName: 'search' }, + ] + const { result } = renderHook( + () => useExternalToolOptions(makeExternalToolBlock({}, tools), 'weather'), + { wrapper }, + ) + // 'weather' regex matches 'WeatherTool'; addOption is always appended + expect(result.current).toHaveLength(2) + expect(result.current[0].key).toBe('WeatherTool') + }) + + it('should return only addOption when no tools match', () => { + const tools: ExternalToolOption[] = [{ name: 'Alpha', variableName: 'alpha' }] + const { result } = renderHook( + () => useExternalToolOptions(makeExternalToolBlock({}, tools), 'zzz'), + { wrapper }, + ) + expect(result.current).toHaveLength(1) + }) + }) + + /** + * addOption – last element in the array. + * Its onSelect calls externalToolBlockType.onAddExternalTool() if provided. + */ + describe('addOption (the last element)', () => { + it('should render addOption VariableMenuItem (with Tool03/ArrowUpRight icons) without crashing', () => { + const { result } = renderHook( + () => useExternalToolOptions(makeExternalToolBlock({}, [])), + { wrapper }, + ) + const lastOption = result.current[result.current.length - 1] + const el = lastOption.renderMenuOption(renderProps) + expect(el).toBeTruthy() + }) + + it('should call onAddExternalTool when addOption is selected and callback provided', () => { + const onAddExternalTool = vi.fn() + const { result } = renderHook( + () => useExternalToolOptions(makeExternalToolBlock({ onAddExternalTool }, [])), + { wrapper }, + ) + const lastOption = result.current[result.current.length - 1] + lastOption.onSelectMenuOption() + expect(onAddExternalTool).toHaveBeenCalledTimes(1) + }) + + it('should NOT throw when onAddExternalTool is undefined and addOption is selected', () => { + // Covers the optional-chaining branch: externalToolBlockType?.onAddExternalTool?.() + const block = makeExternalToolBlock({}, []) + delete block.onAddExternalTool + const { result } = renderHook( + () => useExternalToolOptions(block), + { wrapper }, + ) + const lastOption = result.current[result.current.length - 1] + expect(() => lastOption.onSelectMenuOption()).not.toThrow() + }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════════ +// useOptions +// ═══════════════════════════════════════════════════════════════════════════════ +describe('useOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const wrapper = makeLexicalWrapper() + + /** + * Base case: no arguments → both arrays empty. + */ + describe('with no arguments', () => { + it('should return empty workflowVariableOptions and allFlattenOptions', () => { + const { result } = renderHook(() => useOptions(), { wrapper }) + expect(result.current.workflowVariableOptions).toHaveLength(0) + expect(result.current.allFlattenOptions).toHaveLength(0) + }) + }) + + /** + * allFlattenOptions = promptOptions + variableOptions + externalToolOptions. + */ + describe('allFlattenOptions aggregation', () => { + it('should combine prompt, variable, and external tool options', () => { + const { result } = renderHook( + () => useOptions( + makeContextBlock(), // 1 prompt option + undefined, + undefined, + makeVariableBlock([{ value: 'v1', name: 'v1' }]), // 1 var + 1 addOption = 2 + makeExternalToolBlock({}, [{ name: 't1', variableName: 'tv1' }]), // 1 tool + 1 addOption = 2 + ), + { wrapper }, + ) + // 1 + 2 + 2 = 5 + expect(result.current.allFlattenOptions).toHaveLength(5) + }) + }) + + /** + * workflowVariableOptions – show=false must return []. + */ + describe('workflowVariableOptions when show is false', () => { + it('should return empty array', () => { + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([], { show: false }), + ), + { wrapper }, + ) + expect(result.current.workflowVariableOptions).toHaveLength(0) + }) + }) + + /** + * workflowVariableOptions with existing variables but no synthetic node injection. + */ + describe('workflowVariableOptions with plain variables', () => { + it('should return variables as-is when no special blocks are shown', () => { + const vars: NodeOutPutVar[] = [ + makeNodeOutPutVar('node-1', 'Node One', [makeVar('out', VarType.string)]), + ] + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock(vars), + ), + { wrapper }, + ) + expect(result.current.workflowVariableOptions).toHaveLength(1) + expect(result.current.workflowVariableOptions[0].nodeId).toBe('node-1') + }) + }) + + /** + * workflowVariableBlockType.variables is undefined → defaults to [] via `|| []`. + */ + describe('workflowVariableOptions when variables is undefined', () => { + it('should default to empty array', () => { + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + { show: true, variables: undefined }, + ), + { wrapper }, + ) + // No special block injections and no variables → empty array + expect(result.current.workflowVariableOptions).toHaveLength(0) + }) + }) + + /** + * errorMessageBlockType.show=true and 'error_message' NOT already in the list + * → a synthetic error_message node is prepended via Array.unshift(). + */ + describe('errorMessageBlockType injection', () => { + it('should prepend error_message node when show is true and not already present', () => { + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + undefined, + { show: true } satisfies ErrorMessageBlockType, + ), + { wrapper }, + ) + expect(result.current.workflowVariableOptions[0].nodeId).toBe('error_message') + expect(result.current.workflowVariableOptions[0].vars[0].variable).toBe('error_message') + expect(result.current.workflowVariableOptions[0].vars[0].type).toBe(VarType.string) + }) + + it('should NOT inject error_message when already present in variables', () => { + // The findIndex check ensures deduplication + const existingVars: NodeOutPutVar[] = [ + makeNodeOutPutVar('error_message', 'error_message', [makeVar('error_message', VarType.string)]), + ] + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock(existingVars), + undefined, + undefined, + { show: true } satisfies ErrorMessageBlockType, + ), + { wrapper }, + ) + // Should still be 1, not 2 + const errorNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'error_message') + expect(errorNodes).toHaveLength(1) + }) + + it('should NOT inject error_message when errorMessageBlockType.show is false', () => { + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + undefined, + { show: false } satisfies ErrorMessageBlockType, + ), + { wrapper }, + ) + const errorNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'error_message') + expect(errorNodes).toHaveLength(0) + }) + }) + + /** + * lastRunBlockType.show=true → prepends a 'last_run' synthetic node with VarType.object. + */ + describe('lastRunBlockType injection', () => { + it('should prepend last_run node when show is true and not already present', () => { + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + undefined, + undefined, + { show: true } satisfies LastRunBlockType, + ), + { wrapper }, + ) + expect(result.current.workflowVariableOptions[0].nodeId).toBe('last_run') + expect(result.current.workflowVariableOptions[0].vars[0].type).toBe(VarType.object) + }) + + it('should NOT inject last_run when already present in variables', () => { + const existingVars: NodeOutPutVar[] = [ + makeNodeOutPutVar('last_run', 'last_run', [makeVar('last_run', VarType.object)]), + ] + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock(existingVars), + undefined, + undefined, + undefined, + { show: true } satisfies LastRunBlockType, + ), + { wrapper }, + ) + const lastRunNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'last_run') + expect(lastRunNodes).toHaveLength(1) + }) + + it('should NOT inject last_run when lastRunBlockType.show is false', () => { + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + undefined, + undefined, + { show: false } satisfies LastRunBlockType, + ), + { wrapper }, + ) + const lastRunNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'last_run') + expect(lastRunNodes).toHaveLength(0) + }) + }) + + /** + * currentBlockType injection: + * - When generatorType === 'prompt' the title should be 'current_prompt'. + * - Otherwise the title should be 'current_code'. + */ + describe('currentBlockType injection', () => { + it('should prepend current node with title "current_prompt" when generatorType is prompt', () => { + const currentBlock: CurrentBlockType = { show: true, generatorType: GeneratorType.prompt } + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + currentBlock, + ), + { wrapper }, + ) + const currentNode = result.current.workflowVariableOptions.find(v => v.nodeId === 'current') + expect(currentNode).toBeDefined() + expect(currentNode!.title).toBe('current_prompt') + expect(currentNode!.vars[0].type).toBe(VarType.string) + }) + + it('should prepend current node with title "current_code" when generatorType is not prompt', () => { + // Any generatorType value other than 'prompt' results in 'current_code' + const currentBlock: CurrentBlockType = { show: true, generatorType: GeneratorType.code } + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + currentBlock, + ), + { wrapper }, + ) + const currentNode = result.current.workflowVariableOptions.find(v => v.nodeId === 'current') + expect(currentNode).toBeDefined() + expect(currentNode!.title).toBe('current_code') + }) + + it('should NOT inject current node when already present', () => { + // The findIndex guard prevents double-injection + const existingVars: NodeOutPutVar[] = [ + makeNodeOutPutVar('current', 'current_prompt', [makeVar('current', VarType.string)]), + ] + const currentBlock: CurrentBlockType = { show: true, generatorType: GeneratorType.prompt } + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock(existingVars), + undefined, + currentBlock, + ), + { wrapper }, + ) + const currentNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'current') + expect(currentNodes).toHaveLength(1) + }) + + it('should NOT inject current node when currentBlockType.show is false', () => { + const currentBlock: CurrentBlockType = { show: false, generatorType: GeneratorType.prompt } + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock([]), + undefined, + currentBlock, + ), + { wrapper }, + ) + const currentNodes = result.current.workflowVariableOptions.filter(v => v.nodeId === 'current') + expect(currentNodes).toHaveLength(0) + }) + }) + + /** + * Stacking order: when all three special blocks (error_message, last_run, current) + * are shown, they are prepended with Array.unshift() in the order: + * 1. unshift(error_message) → [error_message, ...base] + * 2. unshift(last_run) → [last_run, error_message, ...base] + * 3. unshift(current) → [current, last_run, error_message, ...base] + */ + describe('stacking order of injected nodes', () => { + it('should place current first, then last_run, then error_message, then base vars', () => { + const baseVars: NodeOutPutVar[] = [makeNodeOutPutVar('base-node', 'Base', [])] + const currentBlock: CurrentBlockType = { show: true, generatorType: GeneratorType.prompt } + const errorBlock: ErrorMessageBlockType = { show: true } + const lastRunBlock: LastRunBlockType = { show: true } + + const { result } = renderHook( + () => useOptions( + undefined, + undefined, + undefined, + undefined, + undefined, + makeWorkflowVariableBlock(baseVars), + undefined, + currentBlock, + errorBlock, + lastRunBlock, + ), + { wrapper }, + ) + + const ids = result.current.workflowVariableOptions.map(v => v.nodeId) + // current is unshifted last, so it ends up at index 0 + expect(ids[0]).toBe('current') + expect(ids[1]).toBe('last_run') + expect(ids[2]).toBe('error_message') + expect(ids[3]).toBe('base-node') + }) + }) + + /** + * Full integration: all prompt blocks visible + variables + tools + workflow vars + + * all three special injections active. + */ + describe('full integration scenario', () => { + it('should return correct combined options when all block types are configured', () => { + const vars: Option[] = [{ value: 'v1', name: 'v1' }] + const tools: ExternalToolOption[] = [{ name: 'tool1', variableName: 'tv1' }] + const wfVars: NodeOutPutVar[] = [makeNodeOutPutVar('node-x', 'NodeX', [])] + + const { result } = renderHook( + () => useOptions( + makeContextBlock(), + makeQueryBlock(), + makeHistoryBlock(), + makeVariableBlock(vars), + makeExternalToolBlock({}, tools), + makeWorkflowVariableBlock(wfVars), + makeRequestURLBlock(), + { show: true, generatorType: GeneratorType.prompt } satisfies CurrentBlockType, + { show: true } satisfies ErrorMessageBlockType, + { show: true } satisfies LastRunBlockType, + 'v1', + ), + { wrapper }, + ) + + // allFlattenOptions: 4 prompt + variable options (v1 matches, + addOption) + tool options (tool1 does NOT match 'v1' → 0 + addOption) + // = 4 + 2 + 1 = 7 + expect(result.current.allFlattenOptions).toHaveLength(7) + + // workflowVariableOptions: current + last_run + error_message + node-x = 4 + expect(result.current.workflowVariableOptions).toHaveLength(4) + expect(result.current.workflowVariableOptions[0].nodeId).toBe('current') + expect(result.current.workflowVariableOptions[1].nodeId).toBe('last_run') + expect(result.current.workflowVariableOptions[2].nodeId).toBe('error_message') + expect(result.current.workflowVariableOptions[3].nodeId).toBe('node-x') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.spec.tsx new file mode 100644 index 0000000000..fd623d39ad --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.spec.tsx @@ -0,0 +1,633 @@ +import type { EventEmitter } from 'ahooks/lib/useEventEmitter' +import type { LexicalEditor } from 'lexical' +import type { + ContextBlockType, + CurrentBlockType, + ErrorMessageBlockType, + LastRunBlockType, + QueryBlockType, + VariableBlockType, + WorkflowVariableBlockType, +} from '../../types' +import type { NodeOutPutVar, Var } from '@/app/components/workflow/types' +import type { EventEmitterValue } from '@/context/event-emitter' +import { LexicalComposer } from '@lexical/react/LexicalComposer' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { ContentEditable } from '@lexical/react/LexicalContentEditable' +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $setSelection, + KEY_ESCAPE_COMMAND, +} from 'lexical' +import * as React from 'react' +import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types' +import { VarType } from '@/app/components/workflow/types' +import { EventEmitterContextProvider, useEventEmitterContextContext } from '@/context/event-emitter' +import { INSERT_CONTEXT_BLOCK_COMMAND } from '../context-block' +import { INSERT_CURRENT_BLOCK_COMMAND } from '../current-block' +import { INSERT_ERROR_MESSAGE_BLOCK_COMMAND } from '../error-message-block' +import { INSERT_LAST_RUN_BLOCK_COMMAND } from '../last-run-block' +import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block' +import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '../workflow-variable-block' +import ComponentPicker from './index' + +// Mock Range.getClientRects / getBoundingClientRect for Lexical menu positioning in JSDOM. +// This mirrors the pattern used by other prompt-editor plugin tests in this repo. +const mockDOMRect = { + x: 100, + y: 100, + width: 100, + height: 20, + top: 100, + right: 200, + bottom: 120, + left: 100, + toJSON: () => ({}), +} + +beforeAll(() => { + Range.prototype.getClientRects = vi.fn(() => { + const rectList = [mockDOMRect] as unknown as DOMRectList + Object.defineProperty(rectList, 'length', { value: 1 }) + Object.defineProperty(rectList, 'item', { value: (index: number) => (index === 0 ? mockDOMRect : null) }) + return rectList + }) + Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect) +}) + +// ─── Typed factories (no `any` / `never`) ──────────────────────────────────── + +function makeContextBlock(overrides: Partial = {}): ContextBlockType { + return { show: true, selectable: true, ...overrides } +} + +function makeQueryBlock(overrides: Partial = {}): QueryBlockType { + return { show: true, selectable: true, ...overrides } +} + +function makeVariableBlock(overrides: Partial = {}): VariableBlockType { + return { show: true, variables: [], ...overrides } +} + +function makeCurrentBlock(overrides: Partial = {}): CurrentBlockType { + return { show: true, generatorType: GeneratorType.prompt, ...overrides } +} + +function makeErrorMessageBlock(overrides: Partial = {}): ErrorMessageBlockType { + return { show: true, ...overrides } +} + +function makeLastRunBlock(overrides: Partial = {}): LastRunBlockType { + return { show: true, ...overrides } +} + +function makeWorkflowNodeVar(variable: string, type: VarType, children?: Var['children']): Var { + return { variable, type, children } +} + +function makeWorkflowVarNode(nodeId: string, title: string, vars: Var[]): NodeOutPutVar { + return { nodeId, title, vars } +} + +function makeWorkflowVariableBlock( + overrides: Partial = {}, + variables: NodeOutPutVar[] = [], +): WorkflowVariableBlockType { + return { show: true, variables, ...overrides } +} + +// ─── Test harness ──────────────────────────────────────────────────────────── + +type Captures = { + editor: LexicalEditor | null + eventEmitter: EventEmitter | null +} + +type ReactFiber = { + child: ReactFiber | null + sibling: ReactFiber | null + return: ReactFiber | null + memoizedState?: unknown +} + +type ReactHook = { + memoizedState?: unknown + next?: ReactHook | null +} + +const CaptureEditorAndEmitter: React.FC<{ captures: Captures }> = ({ captures }) => { + const [editor] = useLexicalComposerContext() + const { eventEmitter } = useEventEmitterContextContext() + + React.useEffect(() => { + captures.editor = editor + }, [captures, editor]) + + React.useEffect(() => { + captures.eventEmitter = eventEmitter + }, [captures, eventEmitter]) + + return null +} + +const CONTENT_EDITABLE_TEST_ID = 'component-picker-ce' + +const MinimalEditor: React.FC<{ + triggerString: string + contextBlock?: ContextBlockType + queryBlock?: QueryBlockType + variableBlock?: VariableBlockType + workflowVariableBlock?: WorkflowVariableBlockType + currentBlock?: CurrentBlockType + errorMessageBlock?: ErrorMessageBlockType + lastRunBlock?: LastRunBlockType + captures: Captures +}> = ({ + triggerString, + contextBlock, + queryBlock, + variableBlock, + workflowVariableBlock, + currentBlock, + errorMessageBlock, + lastRunBlock, + captures, +}) => { + const initialConfig = React.useMemo(() => ({ + namespace: `component-picker-test-${Math.random().toString(16).slice(2)}`, + onError: (e: Error) => { + throw e + }, + }), []) + + return ( + + + } + placeholder={null} + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + ) +} + +async function waitForEditor(captures: Captures): Promise { + await waitFor(() => { + expect(captures.editor).not.toBeNull() + }) + return captures.editor as LexicalEditor +} + +async function waitForEventEmitter(captures: Captures): Promise> { + await waitFor(() => { + expect(captures.eventEmitter).not.toBeNull() + }) + return captures.eventEmitter as NonNullable +} + +async function setEditorText(editor: LexicalEditor, text: string, selectEnd: boolean): Promise { + await act(async () => { + editor.update(() => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + const textNode = $createTextNode(text) + paragraph.append(textNode) + root.append(paragraph) + if (selectEnd) + textNode.selectEnd() + }) + }) +} + +function readEditorText(editor: LexicalEditor): string { + return editor.getEditorState().read(() => $getRoot().getTextContent()) +} + +function getReactFiberFromDom(dom: Element): ReactFiber | null { + const key = Object.keys(dom).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')) + if (!key) + return null + return (dom as unknown as Record)[key] as ReactFiber +} + +function findHookRefPointingToElement(root: ReactFiber, element: Element): { current: unknown } | null { + const visit = (fiber: ReactFiber | null): { current: unknown } | null => { + if (!fiber) + return null + + let hook = fiber.memoizedState as ReactHook | null | undefined + while (hook) { + const state = hook.memoizedState + if (state && typeof state === 'object' && 'current' in state) { + const ref = state as { current: unknown } + if (ref.current === element) + return ref + } + hook = hook.next + } + + return visit(fiber.child) || visit(fiber.sibling) + } + return visit(root) +} + +async function flushNextTick(): Promise { + // Used to flush 0ms setTimeout work scheduled by renderMenu (refs.setReference guard). + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('ComponentPicker (component-picker-block/index.tsx)', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('does not render a menu when there are no options and workflowVariableBlock is not shown (renderMenu returns null)', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + render() + + const editor = await waitForEditor(captures) + await setEditorText(editor, '{', true) + + // Menu should not appear because renderMenu exits early without an anchor + content. + await waitFor(() => { + expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument() + }) + }) + + it('renders prompt options in a portal and removes the trigger TextNode when selecting a normal option (nodeToRemove && key truthy)', async () => { + const user = userEvent.setup() + + const captures: Captures = { editor: null, eventEmitter: null } + render(( + + )) + const editor = await waitForEditor(captures) + const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') + + // Open the typeahead menu by inserting the trigger character at the caret. + await setEditorText(editor, '{', true) + + // The i18n mock returns "common." for { ns: 'common' }. + const contextTitle = await screen.findByText('common.promptEditor.context.item.title') + expect(contextTitle).toBeInTheDocument() + + // Hover over another menu item to trigger `onSetHighlight` -> `setHighlightedIndex(index)`. + const queryTitle = await screen.findByText('common.promptEditor.query.item.title') + const queryItem = queryTitle.closest('[tabindex="-1"]') + expect(queryItem).not.toBeNull() + await user.hover(queryItem as HTMLElement) + + // Flush the 0ms timer in renderMenu that calls refs.setReference(anchor). + await flushNextTick() + + fireEvent.click(contextTitle) + + // Selecting an option should dispatch a command (from the real option implementation). + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_CONTEXT_BLOCK_COMMAND, undefined) + + // The trigger character should be removed from editor content via `nodeToRemove.remove()`. + await waitFor(() => { + expect(readEditorText(editor)).not.toContain('{') + }) + }) + + it('does not remove the trigger when selecting an option with an empty key (nodeToRemove && key falsy)', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + render(( + + )) + const editor = await waitForEditor(captures) + + await setEditorText(editor, '{', true) + + // There is no accessible "option" role here (menu items are plain divs). + // We locate menu items by `tabindex="-1"` inside the listbox. + const listbox = await screen.findByRole('listbox', { name: /typeahead menu/i }) + const menuItems = Array.from(listbox.querySelectorAll('[tabindex="-1"]')) + + // Expect at least: (1) our empty variable option, (2) the "add variable" option. + expect(menuItems.length).toBeGreaterThanOrEqual(2) + expect(within(listbox).getByText('common.promptEditor.variable.modal.add')).toBeInTheDocument() + + fireEvent.click(menuItems[0] as HTMLElement) + + // Since the key is falsy, ComponentPicker should NOT call nodeToRemove.remove(). + // The trigger remains in editor content. + await waitFor(() => { + expect(readEditorText(editor)).toContain('{') + }) + }) + + it('subscribes to EventEmitter and dispatches INSERT_VARIABLE_VALUE_BLOCK_COMMAND only for matching messages', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + render(( + + )) + + const editor = await waitForEditor(captures) + const eventEmitter = await waitForEventEmitter(captures) + const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') + + // Non-object emissions (string) should be ignored by the subscription callback. + eventEmitter.emit('some-string') + expect(dispatchSpy).not.toHaveBeenCalledWith(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, expect.any(String)) + + // Mismatched type should be ignored. + eventEmitter.emit({ type: 'OTHER', payload: 'x' }) + expect(dispatchSpy).not.toHaveBeenCalledWith(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, '{{x}}') + + // Matching type should dispatch with {{payload}} wrapping. + eventEmitter.emit({ type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND as unknown as string, payload: 'foo' }) + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, '{{foo}}') + }) + + it('handles workflow variable selection: flat vars (current/error_message/last_run) and closes on Escape from search input', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + + const workflowVariableBlock = makeWorkflowVariableBlock({}, [ + { nodeId: 'custom-flat', title: 'custom-flat', isFlat: true, vars: [makeWorkflowNodeVar('custom_flat', VarType.string)] }, + makeWorkflowVarNode('node-output', 'Node Output', [ + makeWorkflowNodeVar('output', VarType.string), + ]), + ]) + + render(( + + )) + + const editor = await waitForEditor(captures) + const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') + + // Open menu and select current (flat). + await setEditorText(editor, '{', true) + await flushNextTick() + const currentLabel = await screen.findByText('current_prompt') + await act(async () => { + fireEvent.click(currentLabel) + }) + await flushNextTick() + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_CURRENT_BLOCK_COMMAND, GeneratorType.prompt) + + // Re-open menu and select error_message (flat). + await setEditorText(editor, '{', true) + await flushNextTick() + const errorMessageLabel = await screen.findByText('error_message') + await act(async () => { + fireEvent.click(errorMessageLabel) + }) + await flushNextTick() + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_ERROR_MESSAGE_BLOCK_COMMAND, null) + + // Re-open menu and select last_run (flat). + await setEditorText(editor, '{', true) + await flushNextTick() + const lastRunLabel = await screen.findByText('last_run') + await act(async () => { + fireEvent.click(lastRunLabel) + }) + await flushNextTick() + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_LAST_RUN_BLOCK_COMMAND, null) + + // Re-open menu and press Escape in the VarReferenceVars search input to exercise handleClose(). + await setEditorText(editor, '{', true) + await flushNextTick() + const searchInput = await screen.findByPlaceholderText('workflow.common.searchVar') + await act(async () => { + fireEvent.keyDown(searchInput, { key: 'Escape' }) + }) + await flushNextTick() + expect(dispatchSpy).toHaveBeenCalledWith(KEY_ESCAPE_COMMAND, expect.any(KeyboardEvent)) + + // Re-open menu and select a flat var that is not handled by the special-case list. + // This covers the "no-op" path in the `isFlat` branch. + dispatchSpy.mockClear() + await setEditorText(editor, '{', true) + await flushNextTick() + const customFlatLabel = await screen.findByText('custom_flat') + await act(async () => { + fireEvent.click(customFlatLabel) + }) + await flushNextTick() + expect(dispatchSpy).not.toHaveBeenCalled() + }) + + it('handles workflow variable selection for nested fields: sys.query, sys.files, and normal paths', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + const user = userEvent.setup() + + const workflowVariableBlock = makeWorkflowVariableBlock({}, [ + makeWorkflowVarNode('node-1', 'Node 1', [ + makeWorkflowNodeVar('sys.query', VarType.object, [makeWorkflowNodeVar('q', VarType.string)]), + makeWorkflowNodeVar('sys.files', VarType.object, [makeWorkflowNodeVar('f', VarType.string)]), + makeWorkflowNodeVar('output', VarType.object, [makeWorkflowNodeVar('x', VarType.string)]), + ]), + ]) + + render(( + + )) + + const editor = await waitForEditor(captures) + const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') + + const openPickerAndSelectField = async (variableTitle: string, fieldName: string) => { + await setEditorText(editor, '{', true) + await screen.findByPlaceholderText('workflow.common.searchVar') + await act(async () => { /* flush effects */ }) + + const label = document.querySelector(`[title="${variableTitle}"]`) + expect(label).not.toBeNull() + const row = (label as HTMLElement).parentElement?.parentElement + expect(row).not.toBeNull() + + // `ahooks/useHover` listens for native `mouseenter` / `mouseleave`. `user.hover` triggers + // a realistic event sequence that reliably hits those listeners in JSDOM. + await user.hover(row as HTMLElement) + const field = await screen.findByText(fieldName) + fireEvent.mouseDown(field) + await user.unhover(row as HTMLElement) + } + + await openPickerAndSelectField('sys.query', 'q') + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['sys.query']) + await waitFor(() => expect(readEditorText(editor)).not.toContain('{')) + + await openPickerAndSelectField('sys.files', 'f') + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['sys.files']) + await waitFor(() => expect(readEditorText(editor)).not.toContain('{')) + + await openPickerAndSelectField('output', 'x') + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'output', 'x']) + await waitFor(() => expect(readEditorText(editor)).not.toContain('{')) + }) + + it('skips removing the trigger when selection is null (needRemove is null) and still dispatches', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + + const workflowVariableBlock = makeWorkflowVariableBlock({}, [ + { nodeId: 'current', title: 'current_prompt', isFlat: true, vars: [makeWorkflowNodeVar('current', VarType.string)] }, + ]) + + render(( + + )) + + const editor = await waitForEditor(captures) + const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') + + await setEditorText(editor, '{', true) + const currentLabel = await screen.findByText('current_prompt') + + // Force selection to null and click within the same act() to avoid the typeahead UI unmounting + // before the click handler fires. + await act(async () => { + editor.update(() => { + $setSelection(null) + }) + currentLabel.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_CURRENT_BLOCK_COMMAND, undefined) + await waitFor(() => expect(readEditorText(editor)).toContain('{')) + }) + + it('covers the anchor-ref guard when anchorElementRef.current becomes null before the scheduled callback runs', async () => { + // `@lexical/react` keeps `anchorElementRef.current` as a stable element reference, which means the + // "anchor is null" path is hard to reach through normal interactions in JSDOM. + // + // To reach 100% branch coverage for `index.tsx`, we: + // 1) Pause timers so the scheduled callback doesn't run immediately. + // 2) Find the `useRef` hook object used by LexicalTypeaheadMenuPlugin that points at `#typeahead-menu`. + // 3) Set that ref's `.current = null` before advancing timers. + // + // This avoids mocking third-party modules while still exercising the guard. + vi.useFakeTimers() + + const captures: Captures = { editor: null, eventEmitter: null } + render(( + + )) + + await act(async () => { /* flush effects */ }) + expect(captures.editor).not.toBeNull() + const editor = captures.editor as LexicalEditor + + await setEditorText(editor, '{', true) + const typeaheadMenu = document.getElementById('typeahead-menu') + expect(typeaheadMenu).not.toBeNull() + + const ce = screen.getByTestId(CONTENT_EDITABLE_TEST_ID) + const fiber = getReactFiberFromDom(ce) + expect(fiber).not.toBeNull() + const root = (() => { + let cur = fiber as ReactFiber + while (cur.return) + cur = cur.return + return cur + })() + + const anchorRef = findHookRefPointingToElement(root, typeaheadMenu as Element) + expect(anchorRef).not.toBeNull() + anchorRef!.current = null + + await act(async () => { + vi.runOnlyPendingTimers() + }) + + vi.useRealTimers() + }) + + it('renders the workflow-variable divider when workflowVariableBlock is shown and options are non-empty', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + + const workflowVariableBlock = makeWorkflowVariableBlock({}, [ + makeWorkflowVarNode('node-1', 'Node 1', [ + makeWorkflowNodeVar('output', VarType.string), + ]), + ]) + + render(( + + )) + + const editor = await waitForEditor(captures) + await setEditorText(editor, '{', true) + + // Both sections are present. + expect(await screen.findByPlaceholderText('workflow.common.searchVar')).toBeInTheDocument() + expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument() + + // With a single option group, the only divider should be the workflow-var/options separator. + expect(document.querySelectorAll('.bg-divider-subtle')).toHaveLength(1) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/menu.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/menu.spec.tsx new file mode 100644 index 0000000000..2ee4fb7e0b --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/menu.spec.tsx @@ -0,0 +1,123 @@ +import { render } from '@testing-library/react' +import { Fragment } from 'react' +import { PickerBlockMenuOption } from './menu' + +describe('PickerBlockMenuOption', () => { + // Define the render props type locally to match the component's internal type accurately + type MenuOptionRenderProps = { + isSelected: boolean + onSelect: () => void + onSetHighlight: () => void + queryString: string | null + } + + const mockRender = vi.fn((props: MenuOptionRenderProps) => ( +
+ {props.isSelected ? 'Selected' : 'Not Selected'} + {props.queryString && ` Query: ${props.queryString}`} +
+ )) + const mockOnSelect = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Constructor and Initialization', () => { + it('should correctly initialize with provided key and group', () => { + const option = new PickerBlockMenuOption({ + key: 'test-key', + group: 'test-group', + render: mockRender, + }) + + // Check inheritance from MenuOption (key) + expect(option.key).toBe('test-key') + // Check custom property (group) + expect(option.group).toBe('test-group') + }) + + it('should initialize without group when not provided', () => { + const option = new PickerBlockMenuOption({ + key: 'test-key-no-group', + render: mockRender, + }) + + expect(option.key).toBe('test-key-no-group') + expect(option.group).toBeUndefined() + }) + }) + + describe('onSelectMenuOption', () => { + it('should call the provided onSelect callback', () => { + const option = new PickerBlockMenuOption({ + key: 'test-key', + onSelect: mockOnSelect, + render: mockRender, + }) + + option.onSelectMenuOption() + expect(mockOnSelect).toHaveBeenCalledTimes(1) + }) + + it('should handle cases where onSelect is not provided (optional chaining)', () => { + const option = new PickerBlockMenuOption({ + key: 'test-key', + render: mockRender, + }) + + // This covers the branch where this.data.onSelect is undefined + expect(() => option.onSelectMenuOption()).not.toThrow() + }) + }) + + describe('renderMenuOption', () => { + it('should call the render function with correct props and return the element', () => { + const option = new PickerBlockMenuOption({ + key: 'test-key', + render: mockRender, + }) + + const renderProps: MenuOptionRenderProps = { + isSelected: true, + onSelect: vi.fn(), + onSetHighlight: vi.fn(), + queryString: 'search-string', + } + + // Execute renderMenuOption + const renderedElement = option.renderMenuOption(renderProps) + + // Use RTL to verify the rendered output + const { getByTestId, getByText } = render(renderedElement) + + // Assertions + expect(mockRender).toHaveBeenCalledWith(renderProps) + expect(getByTestId('menu-item')).toBeInTheDocument() + expect(getByText('Selected Query: search-string')).toBeInTheDocument() + }) + + it('should use Fragment with the correct key as the wrapper', () => { + // In React testing, verifying the key of a Fragment directly from the element can be tricky, + // but we can verify the structure and that it renders correctly. + const option = new PickerBlockMenuOption({ + key: 'fragment-key', + render: mockRender, + }) + + const renderProps: MenuOptionRenderProps = { + isSelected: false, + onSelect: vi.fn(), + onSetHighlight: vi.fn(), + queryString: null, + } + + const element = option.renderMenuOption(renderProps) + + // Verify the element type is Fragment (rendered output doesn't show Fragment in DOM) + // but we can check the JSX structure if needed. + expect(element.type).toBe(Fragment) + expect(element.key).toBe('fragment-key') + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/prompt-option.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/prompt-option.spec.tsx new file mode 100644 index 0000000000..c48c52a0b7 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/prompt-option.spec.tsx @@ -0,0 +1,131 @@ +import { createEvent, fireEvent, render, screen } from '@testing-library/react' +import { PromptMenuItem } from './prompt-option' + +describe('PromptMenuItem', () => { + const defaultProps = { + icon: icon, + title: 'Test Option', + isSelected: false, + onClick: vi.fn(), + onMouseEnter: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render the icon and title correctly', () => { + render() + + expect(screen.getByTestId('test-icon')).toBeInTheDocument() + expect(screen.getByText('Test Option')).toBeInTheDocument() + }) + + it('should have the correct display name', () => { + expect(PromptMenuItem.displayName).toBe('PromptMenuItem') + }) + }) + + describe('Styling and States', () => { + it('should apply selected styles when isSelected is true and not disabled', () => { + const { container } = render() + const menuDiv = container.firstChild as HTMLElement + + expect(menuDiv.className).toContain('!bg-state-base-hover') + expect(menuDiv.className).toContain('cursor-pointer') + expect(menuDiv.className).not.toContain('cursor-not-allowed') + }) + + it('should apply disabled styles and ignore isSelected when disabled is true', () => { + const { container } = render( + , + ) + const menuDiv = container.firstChild as HTMLElement + + expect(menuDiv.className).toContain('cursor-not-allowed') + expect(menuDiv.className).toContain('opacity-30') + expect(menuDiv.className).not.toContain('!bg-state-base-hover') + }) + + it('should render with default styles when not selected and not disabled', () => { + const { container } = render() + const menuDiv = container.firstChild as HTMLElement + + expect(menuDiv.className).toContain('cursor-pointer') + expect(menuDiv.className).not.toContain('!bg-state-base-hover') + expect(menuDiv.className).not.toContain('cursor-not-allowed') + }) + }) + + describe('Interactions', () => { + describe('onClick', () => { + it('should call onClick when not disabled', () => { + render() + + fireEvent.click(screen.getByText('Test Option')) + + expect(defaultProps.onClick).toHaveBeenCalledTimes(1) + }) + + it('should NOT call onClick when disabled', () => { + render() + + fireEvent.click(screen.getByText('Test Option')) + + expect(defaultProps.onClick).not.toHaveBeenCalled() + }) + }) + + describe('onMouseEnter', () => { + it('should call onMouseEnter when not disabled', () => { + render() + + fireEvent.mouseEnter(screen.getByText('Test Option')) + + expect(defaultProps.onMouseEnter).toHaveBeenCalledTimes(1) + }) + + it('should NOT call onMouseEnter when disabled', () => { + render() + + fireEvent.mouseEnter(screen.getByText('Test Option')) + + expect(defaultProps.onMouseEnter).not.toHaveBeenCalled() + }) + }) + + describe('onMouseDown', () => { + it('should prevent default and stop propagation', () => { + render() + + const element = screen.getByText('Test Option').parentElement! + + // Use createEvent to properly spy on preventDefault and stopPropagation + const mouseDownEvent = createEvent.mouseDown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + + mouseDownEvent.preventDefault = preventDefault + mouseDownEvent.stopPropagation = stopPropagation + + fireEvent(element, mouseDownEvent) + + expect(preventDefault).toHaveBeenCalled() + expect(stopPropagation).toHaveBeenCalled() + }) + }) + }) + + describe('Reference Management', () => { + it('should call setRefElement with the div element if provided', () => { + const setRefElement = vi.fn() + const { container } = render( + , + ) + + const menuDiv = container.firstChild + expect(setRefElement).toHaveBeenCalledWith(menuDiv) + }) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/variable-option.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/variable-option.spec.tsx new file mode 100644 index 0000000000..228f2ac657 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/variable-option.spec.tsx @@ -0,0 +1,124 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { VariableMenuItem } from './variable-option' + +describe('VariableMenuItem', () => { + const defaultProps = { + title: 'Variable Name', + isSelected: false, + queryString: null, + onClick: vi.fn(), + onMouseEnter: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render the title correctly', () => { + render() + expect(screen.getByText('Variable Name')).toBeInTheDocument() + expect(screen.getByTitle('Variable Name')).toBeInTheDocument() + }) + + it('should render the icon when provided', () => { + render( + icon} + />, + ) + expect(screen.getByTestId('test-icon')).toBeInTheDocument() + }) + + it('should render the extra element when provided', () => { + render( + extra} + />, + ) + expect(screen.getByTestId('extra')).toBeInTheDocument() + }) + + it('should apply selection styles when isSelected is true', () => { + const { container } = render() + const item = container.firstChild as HTMLElement + expect(item).toHaveClass('bg-state-base-hover') + }) + }) + + describe('Highlighting Logic (queryString)', () => { + it('should not highlight anything when queryString is null', () => { + render() + const titleContainer = screen.getByTitle('Variable Name') + // Ensure no highlighted span with text exists + expect(titleContainer.querySelector('.text-text-accent')?.textContent).toBe('') + }) + + it('should highlight matching text case-insensitively', () => { + render() + const highlighted = screen.getByText('User') + expect(highlighted).toHaveClass('text-text-accent') + + const titleContainer = screen.getByTitle('User Name') + expect(titleContainer.textContent).toBe('User Name') + }) + + it('should handle partial match in the middle of the string', () => { + render() + const highlighted = screen.getByText('tem') + expect(highlighted).toHaveClass('text-text-accent') + + const titleContainer = screen.getByTitle('System Variable') + expect(titleContainer.textContent).toBe('System Variable') + expect(titleContainer.innerHTML).toContain('Sys') + expect(titleContainer.innerHTML).toContain(' Variable') + }) + + it('should handle no match gracefully', () => { + render() + expect(screen.getByText('Variable')).toBeInTheDocument() + const titleContainer = screen.getByTitle('Variable') + expect(titleContainer.querySelector('.text-text-accent')?.textContent).toBe('') + }) + }) + + describe('Events', () => { + it('should trigger onClick when clicked', () => { + render() + fireEvent.click(screen.getByTitle('Variable Name')) + expect(defaultProps.onClick).toHaveBeenCalledTimes(1) + }) + + it('should trigger onMouseEnter when mouse enters', () => { + render() + fireEvent.mouseEnter(screen.getByTitle('Variable Name')) + expect(defaultProps.onMouseEnter).toHaveBeenCalledTimes(1) + }) + + it('should prevent default and stop propagation onMouseDown', () => { + render() + const mousedownEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + }) + const preventDefaultSpy = vi.spyOn(mousedownEvent, 'preventDefault') + const stopPropagationSpy = vi.spyOn(mousedownEvent, 'stopPropagation') + + fireEvent(screen.getByTitle('Variable Name'), mousedownEvent) + + expect(preventDefaultSpy).toHaveBeenCalled() + expect(stopPropagationSpy).toHaveBeenCalled() + }) + }) + + describe('Ref handling', () => { + it('should call setRefElement with the element', () => { + const setRefElement = vi.fn() + render() + + expect(setRefElement).toHaveBeenCalledWith(expect.any(HTMLDivElement)) + }) + }) +})