test(workflow): add comprehensive tests for context generation modal components

- Introduced unit tests for the context generation modal, including left, right, and chat view panels.
- Enhanced test coverage for hooks related to context generation, ensuring proper functionality and state management.
- Implemented tests for clipboard functionality in the CopyId component.
- Added tests for input variable list interactions and tooltip feedback.
- Verified language fallback behavior in various components.
- Ensured proper rendering and interaction of UI elements across different states.
This commit is contained in:
CodingOnStar
2026-03-26 13:28:04 +08:00
parent 9ab18b3ef6
commit 8fa5aa9c0d
22 changed files with 5686 additions and 61 deletions

View File

@ -0,0 +1,93 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import CopyId from '../copy-id'
const mockCopy = vi.fn()
let mockTranslationReturnsEmpty = false
vi.mock('copy-to-clipboard', () => ({
default: (...args: unknown[]) => mockCopy(...args),
}))
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
return {
...actual,
useTranslation: () => ({
t: (key: string) => mockTranslationReturnsEmpty ? '' : key,
}),
}
})
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent?: React.ReactNode }) => (
<div>
<div data-testid="tooltip-content">{popupContent}</div>
{children}
</div>
),
}))
describe('CopyId', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockTranslationReturnsEmpty = false
})
afterEach(() => {
vi.useRealTimers()
})
it('should copy content after the debounced click handler runs', () => {
render(<CopyId content="tool-node-id" />)
act(() => {
fireEvent.click(screen.getByText('tool-node-id'))
vi.advanceTimersByTime(100)
})
expect(mockCopy).toHaveBeenCalledWith('tool-node-id')
})
it('should toggle tooltip feedback between copy and copied states', () => {
render(<CopyId content="tool-node-id" />)
expect(screen.getByTestId('tooltip-content')).toHaveTextContent(/embedded\.copy$/)
act(() => {
fireEvent.click(screen.getByText('tool-node-id'))
vi.advanceTimersByTime(100)
})
expect(screen.getByTestId('tooltip-content')).toHaveTextContent(/embedded\.copied$/)
act(() => {
fireEvent.mouseLeave(screen.getByText('tool-node-id').closest('.inline-flex')!)
vi.advanceTimersByTime(100)
})
expect(screen.getByTestId('tooltip-content')).toHaveTextContent(/embedded\.copy$/)
})
it('should stop click propagation from the wrapper container', () => {
const parentClick = vi.fn()
render(
<div onClick={parentClick}>
<CopyId content="tool-node-id" />
</div>,
)
fireEvent.click(screen.getByText('tool-node-id').closest('.inline-flex')!)
expect(parentClick).not.toHaveBeenCalled()
})
it('should fall back to an empty tooltip when translations resolve to empty strings', () => {
mockTranslationReturnsEmpty = true
render(<CopyId content="tool-node-id" />)
expect(screen.getByTestId('tooltip-content')).toBeEmptyDOMElement()
})
})

View File

@ -19,6 +19,7 @@ import InputVarList from '../input-var-list'
const mockUseAvailableVarList = vi.fn()
const mockFetchNextPage = vi.fn()
let mockLanguage = 'en_US'
const mockApps: App[] = [
{
id: 'app-1',
@ -59,7 +60,7 @@ class MockMutationObserver {
}
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
useLanguage: () => mockLanguage,
useModelList: () => ({
data: [{
provider: 'openai',
@ -190,24 +191,33 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference
onOpen,
schema,
defaultVarKindType,
filterVar,
}: {
onChange: (value: string[] | string, kind: ToolVarType) => void
onOpen?: () => void
schema?: { variable?: string }
defaultVarKindType?: ToolVarType
filterVar?: (payload: { type: VarType }) => boolean
}) => (
<button
type="button"
onClick={() => {
onOpen?.()
if (defaultVarKindType === ToolVarType.variable)
onChange(['node-1', 'file'], ToolVarType.variable)
else
onChange('42', defaultVarKindType || ToolVarType.constant)
}}
>
{`pick-${schema?.variable || 'var'}`}
</button>
<div>
<button
type="button"
onClick={() => {
onOpen?.()
if (defaultVarKindType === ToolVarType.variable)
onChange(['node-1', 'file'], ToolVarType.variable)
else
onChange('42', defaultVarKindType || ToolVarType.constant)
}}
>
{`pick-${schema?.variable || 'var'}`}
</button>
{filterVar && (
<div data-testid={`filter-${schema?.variable || 'var'}`}>
{`${String(filterVar({ type: VarType.file }))}:${String(filterVar({ type: VarType.arrayFile }))}:${String(filterVar({ type: VarType.string }))}`}
</div>
)}
</div>
),
}))
@ -356,6 +366,7 @@ describe('InputVarList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en_US'
mockUseAvailableVarList.mockReturnValue({
availableVars: [{
nodeId: 'node-1',
@ -393,6 +404,9 @@ describe('InputVarList', () => {
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('Required')).toBeInTheDocument()
expect(screen.getByText('query-tip')).toBeInTheDocument()
const availableVarConfig = mockUseAvailableVarList.mock.calls[0]?.[1] as { filterVar?: (payload: { type: VarType }) => boolean }
expect(availableVarConfig.filterVar?.({ type: VarType.secret })).toBe(true)
expect(availableVarConfig.filterVar?.({ type: VarType.file })).toBe(false)
await user.type(screen.getByLabelText('workflow.nodes.http.insertVarPlaceholder'), 'hello')
@ -510,4 +524,82 @@ describe('InputVarList', () => {
},
})
})
it('should render tool and select parameter labels and update existing picker values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderInputVarList(
<TestHarness
schema={[
createSchemaItem('tool', FormTypeEnum.toolSelector),
createSchemaItem('choice', FormTypeEnum.dynamicSelect),
createSchemaItem('attachment', FormTypeEnum.files),
]}
initialValue={{
choice: {
type: ToolVarType.variable,
value: ['node-1', 'existing'],
},
}}
onChangeSpy={onChange}
/>,
)
expect(screen.getByText('ToolSelector')).toBeInTheDocument()
expect(screen.getByText('Select')).toBeInTheDocument()
expect(screen.getByText('Files')).toBeInTheDocument()
expect(screen.getByTestId('filter-var')).toHaveTextContent('true:true:false')
await user.click(screen.getByRole('button', { name: 'pick-choice' }))
expect(onChange).toHaveBeenCalledWith({
choice: {
type: ToolVarType.variable,
value: ['node-1', 'file'],
},
})
})
it('should fall back to en_US labels and tooltips for the active language and preserve constant picker defaults', async () => {
mockLanguage = 'zh_Hans'
const user = userEvent.setup()
const onChange = vi.fn()
renderInputVarList(
<TestHarness
schema={[
createSchemaItem('limit', FormTypeEnum.textNumber, {
label: {
en_US: 'Limit',
zh_Hans: '',
},
tooltip: {
en_US: 'Limit tooltip',
zh_Hans: '',
},
}),
]}
initialValue={{
limit: {
type: ToolVarType.constant,
value: '5',
},
}}
onChangeSpy={onChange}
/>,
)
expect(screen.getByText('Limit')).toBeInTheDocument()
expect(screen.getByText('Limit tooltip')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'pick-limit' }))
expect(onChange).toHaveBeenCalledWith({
limit: {
type: ToolVarType.constant,
value: '42',
},
})
})
})

View File

@ -0,0 +1,554 @@
import type { ContextGenerateModalHandle } from '../index'
import type { ContextGenerateResponse } from '@/service/debug'
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { NodeRunningStatus, VarType } from '@/app/components/workflow/types'
import ContextGenerateModal from '../index'
const {
mockHandleFetchSuggestedQuestions,
mockAbortSuggestedQuestions,
mockResetSuggestions,
mockHandleReset,
mockHandleGenerate,
mockHandleModelChange,
mockHandleCompletionParamsChange,
mockHandleNodeDataUpdateWithSyncDraft,
mockSetInitShowLastRunTab,
mockSetPendingSingleRun,
mockLeftPanel,
mockRightPanel,
} = vi.hoisted(() => ({
mockHandleFetchSuggestedQuestions: vi.fn(() => Promise.resolve()),
mockAbortSuggestedQuestions: vi.fn(),
mockResetSuggestions: vi.fn(),
mockHandleReset: vi.fn(),
mockHandleGenerate: vi.fn(),
mockHandleModelChange: vi.fn(),
mockHandleCompletionParamsChange: vi.fn(),
mockHandleNodeDataUpdateWithSyncDraft: vi.fn(),
mockSetInitShowLastRunTab: vi.fn(),
mockSetPendingSingleRun: vi.fn(),
mockLeftPanel: vi.fn(),
mockRightPanel: vi.fn(),
}))
let mockConfigsMap: { flowId?: string } | undefined = { flowId: 'flow-1' }
type MockWorkflowNode = {
id: string
data: {
code_language: CodeLanguage
code: string
outputs?: {
result: { type: VarType, children: null }
}
variables?: Array<{ variable: string, value_selector: string[] | null }>
_singleRunningStatus?: NodeRunningStatus
}
}
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({
children,
onOpenChange,
}: {
children: React.ReactNode
onOpenChange?: (open: boolean) => void
}) => (
<div data-testid="dialog">
<button type="button" onClick={() => onOpenChange?.(false)}>dialog-close</button>
<button type="button" onClick={() => onOpenChange?.(true)}>dialog-open</button>
{children}
</div>
),
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogCloseButton: () => <button type="button">close-button</button>,
}))
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: (selector: (state: { configsMap: typeof mockConfigsMap }) => unknown) => selector({
configsMap: mockConfigsMap,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-node-data-update', () => ({
useNodeDataUpdate: () => ({
handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft,
}),
}))
const workflowNodes: MockWorkflowNode[] = [
{
id: 'code-node',
data: {
code_language: CodeLanguage.python3,
code: 'print("hello")',
outputs: {
result: { type: VarType.string, children: null },
},
variables: [{ variable: 'result', value_selector: ['result'] }],
_singleRunningStatus: NodeRunningStatus.NotStart,
},
},
]
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { nodes: typeof workflowNodes }) => unknown) => selector({ nodes: workflowNodes }),
useWorkflowStore: () => ({
getState: () => ({
setInitShowLastRunTab: mockSetInitShowLastRunTab,
setPendingSingleRun: mockSetPendingSingleRun,
}),
}),
}))
vi.mock('../hooks/use-resizable-panels', () => ({
default: () => ({
rightContainerRef: { current: null },
resolvedCodePanelHeight: 240,
handleResizeStart: vi.fn(),
}),
}))
const defaultCurrentVersion: ContextGenerateResponse = {
variables: [{ variable: 'result', value_selector: ['result'] }],
outputs: { result: { type: 'string' } },
code_language: CodeLanguage.javascript,
code: 'return result',
message: '',
error: '',
}
const mockUseContextGenerate = vi.fn()
vi.mock('../hooks/use-context-generate', async () => {
const actual = await vi.importActual<typeof import('../hooks/use-context-generate')>('../hooks/use-context-generate')
return {
...actual,
default: (...args: unknown[]) => mockUseContextGenerate(...args),
}
})
vi.mock('../components/left-panel', () => ({
default: (props: {
onReset?: () => void
}) => {
mockLeftPanel(props)
return (
<div data-testid="left-panel">
<button type="button" onClick={() => props.onReset?.()}>left-reset</button>
</div>
)
},
}))
vi.mock('../components/right-panel', () => ({
default: (props: {
onRun?: () => void
onApply?: () => void
onClose?: () => void
}) => {
mockRightPanel(props)
return (
<div data-testid="right-panel">
<button type="button" onClick={() => props.onRun?.()}>run</button>
<button type="button" onClick={() => props.onApply?.()}>apply</button>
<button type="button" onClick={() => props.onClose?.()}>close</button>
</div>
)
},
}))
type MockContextGenerateReturn = {
current: ContextGenerateResponse | null
currentVersionIndex: number
setCurrentVersionIndex: ReturnType<typeof vi.fn>
promptMessages: Array<{ id?: string, role: string, content: string }>
inputValue: string
setInputValue: ReturnType<typeof vi.fn>
suggestedQuestions: string[]
hasFetchedSuggestions: boolean
isGenerating: boolean
model: {
provider: string
name: string
mode: string
completion_params: Record<string, unknown>
}
handleModelChange: typeof mockHandleModelChange
handleCompletionParamsChange: typeof mockHandleCompletionParamsChange
handleGenerate: typeof mockHandleGenerate
handleReset: typeof mockHandleReset
handleFetchSuggestedQuestions: typeof mockHandleFetchSuggestedQuestions
abortSuggestedQuestions: typeof mockAbortSuggestedQuestions
resetSuggestions: typeof mockResetSuggestions
defaultAssistantMessage: string
versionOptions: Array<{ index: number, label: string }>
currentVersionLabel: string
isInitView: boolean
availableVars: unknown[]
availableNodes: unknown[]
}
const createHookReturn = (overrides: Partial<MockContextGenerateReturn> = {}): MockContextGenerateReturn => ({
current: defaultCurrentVersion,
currentVersionIndex: 0,
setCurrentVersionIndex: vi.fn(),
promptMessages: [],
inputValue: '',
setInputValue: vi.fn(),
suggestedQuestions: [],
hasFetchedSuggestions: true,
isGenerating: false,
model: {
provider: 'openai',
name: 'gpt-4o',
mode: 'chat',
completion_params: {},
},
handleModelChange: mockHandleModelChange,
handleCompletionParamsChange: mockHandleCompletionParamsChange,
handleGenerate: mockHandleGenerate,
handleReset: mockHandleReset,
handleFetchSuggestedQuestions: mockHandleFetchSuggestedQuestions,
abortSuggestedQuestions: mockAbortSuggestedQuestions,
resetSuggestions: mockResetSuggestions,
defaultAssistantMessage: 'Default assistant message',
versionOptions: [{ index: 0, label: 'Version 1' }],
currentVersionLabel: 'Version 1',
isInitView: false,
availableVars: [],
availableNodes: [],
...overrides,
})
describe('ContextGenerateModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfigsMap = { flowId: 'flow-1' }
mockUseContextGenerate.mockReturnValue(createHookReturn())
workflowNodes[0].data = {
code_language: CodeLanguage.python3,
code: 'print("hello")',
outputs: {
result: { type: VarType.string, children: null },
},
variables: [{ variable: 'result', value_selector: ['result'] }],
_singleRunningStatus: NodeRunningStatus.NotStart,
}
})
it('should expose onOpen through the imperative ref and pass fallback data to the right panel', async () => {
const ref = { current: null } as React.MutableRefObject<ContextGenerateModalHandle | null>
render(
<ContextGenerateModal
ref={ref}
isShow
onClose={vi.fn()}
toolNodeId="tool-node"
paramKey="query"
codeNodeId="code-node"
/>,
)
await act(async () => {
ref.current?.onOpen()
})
expect(mockHandleFetchSuggestedQuestions).toHaveBeenCalledTimes(1)
expect(mockRightPanel).toHaveBeenCalledWith(expect.objectContaining({
displayVersion: defaultCurrentVersion,
canApply: true,
canRun: true,
}))
})
it('should apply generated code to the node and close when apply is triggered', () => {
const onClose = vi.fn()
render(
<ContextGenerateModal
isShow
onClose={onClose}
toolNodeId="tool-node"
paramKey="query"
codeNodeId="code-node"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'apply' }))
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(expect.objectContaining({
id: 'code-node',
data: expect.objectContaining({
code_language: CodeLanguage.javascript,
code: 'return result',
}),
}), { sync: true })
expect(mockAbortSuggestedQuestions).toHaveBeenCalled()
expect(mockResetSuggestions).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
})
it('should fall back to pending single run when there is no internal view callback', () => {
render(
<ContextGenerateModal
isShow
onClose={vi.fn()}
toolNodeId="tool-node"
paramKey="query"
codeNodeId="code-node"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'run' }))
expect(mockSetInitShowLastRunTab).toHaveBeenCalledWith(true)
expect(mockSetPendingSingleRun).toHaveBeenCalledWith({
nodeId: 'code-node',
action: 'run',
})
})
it('should delegate run to the internal view flow when provided', () => {
const onOpenInternalViewAndRun = vi.fn()
const onClose = vi.fn()
render(
<ContextGenerateModal
isShow
onClose={onClose}
toolNodeId="tool-node"
paramKey="query"
codeNodeId="code-node"
onOpenInternalViewAndRun={onOpenInternalViewAndRun}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'run' }))
expect(onOpenInternalViewAndRun).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
expect(mockSetPendingSingleRun).not.toHaveBeenCalled()
})
it('should render fallback code data when there is no generated version and expose non-runnable empty states', () => {
mockUseContextGenerate.mockReturnValue(createHookReturn({
current: null,
isInitView: false,
}))
render(
<ContextGenerateModal
isShow
onClose={vi.fn()}
toolNodeId="tool-node"
paramKey="query"
codeNodeId="code-node"
/>,
)
expect(mockRightPanel).toHaveBeenCalledWith(expect.objectContaining({
displayVersion: expect.objectContaining({
code: 'print("hello")',
}),
canApply: false,
canRun: true,
}))
})
it('should handle reset and dialog close flows by refetching suggestions and clearing transient state', () => {
const onClose = vi.fn()
render(
<ContextGenerateModal
isShow
onClose={onClose}
toolNodeId="tool-node"
paramKey="query"
codeNodeId="code-node"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'left-reset' }))
fireEvent.click(screen.getByRole('button', { name: 'dialog-close' }))
expect(mockAbortSuggestedQuestions).toHaveBeenCalledTimes(2)
expect(mockHandleReset).toHaveBeenCalledTimes(1)
expect(mockResetSuggestions).toHaveBeenCalledTimes(2)
expect(mockHandleFetchSuggestedQuestions).toHaveBeenCalledWith({ force: true })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should treat running code nodes as running and no-op run when the code node id is missing', () => {
workflowNodes[0].data._singleRunningStatus = NodeRunningStatus.Running
mockUseContextGenerate.mockReturnValue(createHookReturn({
current: null,
}))
render(
<ContextGenerateModal
isShow
onClose={vi.fn()}
toolNodeId="tool-node"
paramKey="query"
codeNodeId=""
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'run' }))
expect(mockRightPanel).toHaveBeenCalledWith(expect.objectContaining({
isRunning: false,
canRun: false,
canApply: false,
}))
expect(mockSetPendingSingleRun).not.toHaveBeenCalled()
})
it('should build fallback data from the stored code node when outputs are missing and the flow id is absent', () => {
mockConfigsMap = undefined
workflowNodes[0].data = {
code_language: CodeLanguage.python3,
code: '',
outputs: undefined,
variables: [{ variable: 'result', value_selector: null }],
_singleRunningStatus: NodeRunningStatus.NotStart,
}
mockUseContextGenerate.mockReturnValue(createHookReturn({
current: null,
isInitView: false,
}))
render(
<ContextGenerateModal
isShow
onClose={vi.fn()}
toolNodeId="tool-node"
paramKey="query"
codeNodeId="code-node"
/>,
)
expect(mockUseContextGenerate).toHaveBeenCalledWith(expect.objectContaining({
storageKey: 'unknown-tool-node-query',
}))
expect(mockRightPanel).toHaveBeenCalledWith(expect.objectContaining({
displayVersion: expect.objectContaining({
code: '',
outputs: {},
variables: [{ variable: 'result', value_selector: [] }],
}),
canRun: false,
}))
})
it('should keep the modal open when the dialog open state stays true and expose the init-view right panel state', () => {
const onClose = vi.fn()
mockUseContextGenerate.mockReturnValue(createHookReturn({
current: null,
isInitView: true,
}))
render(
<ContextGenerateModal
isShow
onClose={onClose}
toolNodeId="tool-node"
paramKey="query"
codeNodeId="code-node"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'dialog-open' }))
expect(onClose).not.toHaveBeenCalled()
expect(mockRightPanel).toHaveBeenCalledWith(expect.objectContaining({
isInitView: true,
displayVersion: null,
}))
})
it('should skip applying when current data is missing and normalize unsupported output types on apply', () => {
const onClose = vi.fn()
mockUseContextGenerate.mockReturnValueOnce(createHookReturn({
current: null,
isInitView: false,
}))
const { rerender } = render(
<ContextGenerateModal
isShow
onClose={onClose}
toolNodeId="tool-node"
paramKey="query"
codeNodeId="missing-node"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'apply' }))
expect(mockHandleNodeDataUpdateWithSyncDraft).not.toHaveBeenCalled()
mockUseContextGenerate.mockReturnValue(createHookReturn({
current: {
...defaultCurrentVersion,
outputs: {
custom: { type: 'unsupported-type' },
},
variables: [{ variable: 'custom', value_selector: null as unknown as string[] }],
},
isInitView: false,
}))
rerender(
<ContextGenerateModal
isShow
onClose={onClose}
toolNodeId="tool-node"
paramKey="query"
codeNodeId="code-node"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'apply' }))
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(expect.objectContaining({
id: 'code-node',
data: expect.objectContaining({
outputs: {
custom: {
type: VarType.string,
children: null,
},
},
variables: [{ variable: 'custom', value_selector: [] }],
}),
}), { sync: true })
})
it('should run without applying when no current generated version exists', () => {
const onOpenInternalViewAndRun = vi.fn()
mockUseContextGenerate.mockReturnValue(createHookReturn({
current: null,
isInitView: false,
}))
render(
<ContextGenerateModal
isShow
onClose={vi.fn()}
toolNodeId="tool-node"
paramKey="query"
codeNodeId="code-node"
onOpenInternalViewAndRun={onOpenInternalViewAndRun}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'run' }))
expect(mockHandleNodeDataUpdateWithSyncDraft).not.toHaveBeenCalled()
expect(onOpenInternalViewAndRun).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,170 @@
import type { ContextGenerateChatMessage } from '../../hooks/use-context-generate'
import type { VersionOption } from '../../types'
import type { WorkflowVariableBlockType } from '@/app/components/base/prompt-editor/types'
import type { Model } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import ChatView from '../chat-view'
const mockPromptEditor = vi.fn()
vi.mock('@/app/components/base/prompt-editor', () => ({
default: (props: {
value: string
editable: boolean
onChange: (value: string) => void
onEnter: () => void
}) => {
mockPromptEditor(props)
return (
<div>
<textarea
aria-label="prompt-editor"
value={props.value}
readOnly={!props.editable}
onChange={e => props.onChange(e.target.value)}
/>
<button type="button" onClick={props.onEnter}>enter</button>
</div>
)
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
default: ({ renderTrigger }: { renderTrigger: (params: Record<string, unknown>) => React.ReactNode }) => (
<div data-testid="model-parameter-modal">
{renderTrigger({
currentModel: { label: { en_US: 'GPT-4o' }, model: 'gpt-4o' },
currentProvider: { provider: 'openai' },
modelId: 'gpt-4o',
disabled: false,
})}
</div>
),
}))
vi.mock('@/app/components/base/chat/chat/loading-anim', () => ({
default: () => <div data-testid="loading-anim" />,
}))
const defaultMessages: ContextGenerateChatMessage[] = [
{ id: 'user-1', role: 'user', content: 'Summarize the docs' },
{ id: 'assistant-1', role: 'assistant', content: 'Version 1 result' },
]
const defaultVersionOptions: VersionOption[] = [
{ index: 0, label: 'Version 1' },
]
const defaultModel: Model = {
provider: 'openai',
name: 'gpt-4o',
mode: 'chat',
completion_params: {},
} as Model
const defaultWorkflowVariableBlock: WorkflowVariableBlockType = {
show: true,
variables: [],
workflowNodesMap: {},
}
const renderChatView = (overrides: Partial<React.ComponentProps<typeof ChatView>> = {}) => {
const props: React.ComponentProps<typeof ChatView> = {
promptMessages: defaultMessages,
versionOptions: defaultVersionOptions,
currentVersionIndex: 0,
onSelectVersion: vi.fn(),
defaultAssistantMessage: 'Default assistant message',
isGenerating: false,
inputValue: '',
onInputChange: vi.fn(),
onGenerate: vi.fn(),
model: defaultModel,
onModelChange: vi.fn(),
onCompletionParamsChange: vi.fn(),
renderModelTrigger: () => <span>model trigger</span>,
workflowVariableBlock: defaultWorkflowVariableBlock,
...overrides,
}
return {
...render(<ChatView {...props} />),
props,
}
}
describe('ChatView', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render messages and allow selecting an assistant version', () => {
const { props } = renderChatView()
expect(screen.getByText('Summarize the docs')).toBeInTheDocument()
expect(screen.getByText('Version 1 result')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Version 1' }))
expect(props.onSelectVersion).toHaveBeenCalledWith(0)
})
it('should surface generating state and wire editor interactions', () => {
const { props } = renderChatView({
isGenerating: true,
inputValue: 'Generate a prompt',
})
expect(screen.getByTestId('loading-anim')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.tool.contextGenerate.generating')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('prompt-editor'), { target: { value: 'Refine this' } })
fireEvent.click(screen.getByRole('button', { name: 'enter' }))
expect(props.onInputChange).toHaveBeenCalledWith('Refine this')
expect(props.onGenerate).toHaveBeenCalledTimes(1)
})
it('should render fallback assistant content without a version selector when version metadata is missing', () => {
renderChatView({
promptMessages: [{ role: 'assistant', content: '' }],
versionOptions: [],
inputValue: ' ',
})
expect(screen.getByText('Default assistant message')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Version/i })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'enter' })).toBeInTheDocument()
})
it('should render an empty chat state without the generating indicator', () => {
renderChatView({
promptMessages: [],
versionOptions: [],
isGenerating: false,
})
expect(screen.queryByTestId('loading-anim')).not.toBeInTheDocument()
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
editable: true,
value: '',
}))
})
it('should render selected and unselected assistant version cards', () => {
renderChatView({
promptMessages: [
{ role: 'assistant', content: 'Version 1 result' },
{ role: 'assistant', content: 'Version 2 result' },
],
versionOptions: [
{ index: 0, label: 'Version 1' },
{ index: 1, label: 'Version 2' },
],
currentVersionIndex: 1,
})
expect(screen.getByRole('button', { name: 'Version 1' })).toHaveClass('border-components-panel-border-subtle')
expect(screen.getByRole('button', { name: 'Version 2' })).toHaveClass('border-components-option-card-option-selected-border')
})
})

View File

@ -0,0 +1,340 @@
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
import type { Model } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import LeftPanel from '../left-panel'
const mockPromptEditor = vi.fn()
const mockChatView = vi.fn()
let mockLanguage = 'en-US'
let mockModelTriggerParams: Record<string, unknown> = {
currentModel: { label: { en_US: 'GPT-4o' }, model: 'gpt-4o' },
currentProvider: { provider: 'openai' },
modelId: 'gpt-4o',
disabled: false,
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: {
language: mockLanguage,
},
}),
}))
vi.mock('@/app/components/base/action-button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled }: { children: React.ReactNode, onClick?: () => void, disabled?: boolean }) => (
<button type="button" onClick={onClick} disabled={disabled}>{children}</button>
),
}))
vi.mock('@/app/components/base/prompt-editor', () => ({
default: (props: {
value: string
onChange: (value: string) => void
onEnter: () => void
}) => {
mockPromptEditor(props)
return (
<div>
<textarea aria-label="init-editor" value={props.value} onChange={e => props.onChange(e.target.value)} />
<button type="button" onClick={props.onEnter}>editor-enter</button>
</div>
)
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
default: ({ renderTrigger }: { renderTrigger: (params: Record<string, unknown>) => React.ReactNode }) => (
<div data-testid="model-parameter-modal">
{renderTrigger(mockModelTriggerParams)}
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
default: () => <span data-testid="model-icon" />,
}))
vi.mock('@/app/components/base/skeleton', () => ({
SkeletonRectangle: ({ className }: { className?: string }) => <div data-testid="skeleton-rectangle" className={className} />,
SkeletonRow: ({ children }: { children: React.ReactNode }) => <div data-testid="skeleton-row">{children}</div>,
}))
vi.mock('../chat-view', () => ({
default: (props: Record<string, unknown>) => {
mockChatView(props)
return <div data-testid="chat-view" />
},
}))
const defaultModel: Model = {
provider: 'openai',
name: 'gpt-4o',
mode: 'chat',
completion_params: {},
} as Model
const availableNodes: Node[] = [
{
id: 'start-node',
data: {
title: 'Start',
type: BlockEnum.Start,
height: 120,
width: 320,
position: { x: 0, y: 0 },
},
} as Node,
]
const availableVars: NodeOutPutVar[] = [
{
nodeId: 'start-node',
title: 'Start',
vars: [],
},
]
const renderLeftPanel = (overrides: Partial<React.ComponentProps<typeof LeftPanel>> = {}) => {
const props: React.ComponentProps<typeof LeftPanel> = {
isInitView: true,
isGenerating: false,
inputValue: '',
onInputChange: vi.fn(),
onGenerate: vi.fn(),
onReset: vi.fn(),
suggestedQuestions: ['How should I validate this payload?'],
hasFetchedSuggestions: true,
model: defaultModel,
onModelChange: vi.fn(),
onCompletionParamsChange: vi.fn(),
promptMessages: [],
versionOptions: [],
currentVersionIndex: 0,
onSelectVersion: vi.fn(),
defaultAssistantMessage: 'Assistant default',
availableVars,
availableNodes,
...overrides,
}
return {
...render(<LeftPanel {...props} />),
props,
}
}
describe('LeftPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en-US'
mockModelTriggerParams = {
currentModel: { label: { en_US: 'GPT-4o' }, model: 'gpt-4o' },
currentProvider: { provider: 'openai' },
modelId: 'gpt-4o',
disabled: false,
}
})
it('should render the init view and let users reuse a suggested question', () => {
const { props } = renderLeftPanel()
expect(screen.getByText('nodes.tool.contextGenerate.title')).toBeInTheDocument()
expect(screen.getByText('nodes.tool.contextGenerate.subtitle')).toBeInTheDocument()
expect(screen.getByText('How should I validate this payload?')).toBeInTheDocument()
fireEvent.click(screen.getByText('How should I validate this payload?'))
expect(props.onInputChange).toHaveBeenCalledWith('How should I validate this payload?')
})
it('should show skeletons before suggestions are fetched and switch to chat mode later', () => {
const { props } = renderLeftPanel({
hasFetchedSuggestions: false,
})
expect(screen.getAllByTestId('skeleton-row')).toHaveLength(3)
renderLeftPanel({
isInitView: false,
promptMessages: [{ id: 'assistant-1', role: 'assistant', content: 'Done' }],
versionOptions: [{ index: 0, label: 'Version 1' }],
})
expect(screen.getByTestId('chat-view')).toBeInTheDocument()
expect(props.onReset).not.toHaveBeenCalled()
expect(mockChatView).toHaveBeenCalledWith(expect.objectContaining({
defaultAssistantMessage: 'Assistant default',
}))
})
it('should wire init editor submission and forward workflow variables to the prompt editor', () => {
mockModelTriggerParams = {
currentModel: { model: 'fallback-model' },
currentProvider: { provider: 'openai' },
modelId: 'fallback-model',
disabled: true,
}
const { props } = renderLeftPanel({
inputValue: 'Plan the extractor',
availableNodes: [
...availableNodes,
{
id: 'llm-node',
data: {
title: 'LLM',
type: BlockEnum.LLM,
height: 100,
width: 200,
position: { x: 10, y: 10 },
},
} as Node,
],
})
fireEvent.change(screen.getByLabelText('init-editor'), { target: { value: 'Rewrite the prompt' } })
fireEvent.click(screen.getByRole('button', { name: 'editor-enter' }))
expect(props.onInputChange).toHaveBeenCalledWith('Rewrite the prompt')
expect(props.onGenerate).toHaveBeenCalledTimes(1)
expect(screen.getByText('fallback-model')).toBeInTheDocument()
expect(mockPromptEditor).toHaveBeenLastCalledWith(expect.objectContaining({
workflowVariableBlock: expect.objectContaining({
workflowNodesMap: expect.objectContaining({
'start-node': expect.objectContaining({ title: 'Start' }),
'sys': expect.objectContaining({ title: 'blocks.start' }),
'llm-node': expect.objectContaining({ title: 'LLM' }),
}),
}),
}))
})
it('should render fallback model labels and reset from the chat header when not in init view', () => {
const onReset = vi.fn()
renderLeftPanel({
isInitView: false,
isGenerating: true,
onReset,
promptMessages: [{ id: 'assistant-1', role: 'assistant', content: 'Done' }],
versionOptions: [{ index: 0, label: 'Version 1' }],
})
fireEvent.click(screen.getAllByRole('button')[0]!)
expect(onReset).toHaveBeenCalledTimes(1)
expect(mockChatView).toHaveBeenCalledWith(expect.objectContaining({
model: expect.objectContaining({
name: 'gpt-4o',
}),
}))
})
it('should fall back through model id and model name when translated labels are unavailable', () => {
mockLanguage = ''
mockModelTriggerParams = {
currentModel: { label: { en_US: 'Fallback label' } },
currentProvider: { provider: 'openai' },
modelId: '',
disabled: false,
}
const { rerender } = render(
<LeftPanel
isInitView
isGenerating={false}
inputValue=""
onInputChange={vi.fn()}
onGenerate={vi.fn()}
onReset={vi.fn()}
suggestedQuestions={[]}
hasFetchedSuggestions
model={{ ...defaultModel, name: 'model-name-fallback' }}
onModelChange={vi.fn()}
onCompletionParamsChange={vi.fn()}
promptMessages={[]}
versionOptions={[]}
currentVersionIndex={0}
onSelectVersion={vi.fn()}
defaultAssistantMessage="Assistant default"
availableVars={availableVars}
availableNodes={availableNodes}
/>,
)
expect(screen.getByText('Fallback label')).toBeInTheDocument()
mockModelTriggerParams = {
currentModel: {},
currentProvider: { provider: 'openai' },
modelId: 'model-id-fallback',
disabled: false,
}
rerender(
<LeftPanel
isInitView
isGenerating={false}
inputValue=""
onInputChange={vi.fn()}
onGenerate={vi.fn()}
onReset={vi.fn()}
suggestedQuestions={[]}
hasFetchedSuggestions
model={{ ...defaultModel, name: 'model-name-fallback' }}
onModelChange={vi.fn()}
onCompletionParamsChange={vi.fn()}
promptMessages={[]}
versionOptions={[]}
currentVersionIndex={0}
onSelectVersion={vi.fn()}
defaultAssistantMessage="Assistant default"
availableVars={availableVars}
availableNodes={availableNodes}
/>,
)
expect(screen.getByText('model-id-fallback')).toBeInTheDocument()
mockModelTriggerParams = {
currentModel: {},
currentProvider: { provider: 'openai' },
modelId: '',
disabled: false,
}
rerender(
<LeftPanel
isInitView
isGenerating={false}
inputValue=""
onInputChange={vi.fn()}
onGenerate={vi.fn()}
onReset={vi.fn()}
suggestedQuestions={[]}
hasFetchedSuggestions
model={{ ...defaultModel, name: 'model-name-fallback' }}
onModelChange={vi.fn()}
onCompletionParamsChange={vi.fn()}
promptMessages={[]}
versionOptions={[]}
currentVersionIndex={0}
onSelectVersion={vi.fn()}
defaultAssistantMessage="Assistant default"
availableVars={availableVars}
availableNodes={availableNodes}
/>,
)
expect(screen.getByText('model-name-fallback')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,177 @@
import type { ContextGenerateResponse } from '@/service/debug'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import RightPanel from '../right-panel'
vi.mock('@/app/components/base/action-button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled }: { children: React.ReactNode, onClick?: () => void, disabled?: boolean }) => (
<button type="button" onClick={onClick} disabled={disabled}>{children}</button>
),
}))
vi.mock('@/app/components/base/copy-feedback', () => ({
CopyFeedbackNew: ({ content }: { content: string }) => <div data-testid="copy-feedback">{content}</div>,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading" />,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
onOpenChange,
}: {
children: React.ReactNode
onOpenChange?: (open: boolean) => void
}) => (
<div>
<button type="button" onClick={() => onOpenChange?.(true)}>portal-open</button>
<button type="button" onClick={() => onOpenChange?.(false)}>portal-close</button>
{children}
</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) =>
React.isValidElement(children)
? React.cloneElement(children, { onClick } as Record<string, unknown>)
: <div onClick={onClick}>{children}</div>,
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value, language }: { value: unknown, language: string }) => (
<div data-testid={`code-editor-${language}`}>{typeof value === 'string' ? value : JSON.stringify(value)}</div>
),
}))
const defaultVersion: ContextGenerateResponse = {
variables: [{ variable: 'result', value_selector: ['result'] }],
outputs: { result: { type: 'string' } },
code_language: CodeLanguage.javascript,
code: 'return input',
message: '',
error: '',
}
const renderRightPanel = (overrides: Partial<React.ComponentProps<typeof RightPanel>> = {}) => {
const props: React.ComponentProps<typeof RightPanel> = {
isInitView: false,
isGenerating: false,
displayVersion: defaultVersion,
displayCodeLanguage: CodeLanguage.javascript,
displayOutputData: { variables: defaultVersion.variables, outputs: defaultVersion.outputs },
rightContainerRef: { current: null },
resolvedCodePanelHeight: 220,
onResizeStart: vi.fn(),
versionOptions: [{ index: 0, label: 'Version 1' }, { index: 1, label: 'Version 2' }],
currentVersionIndex: 0,
currentVersionLabel: 'Version 1',
onSelectVersion: vi.fn(),
onRun: vi.fn(),
onApply: vi.fn(),
canRun: true,
canApply: true,
isRunning: false,
onClose: vi.fn(),
...overrides,
}
return {
...render(<RightPanel {...props} />),
props,
}
}
describe('RightPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the empty placeholder while the init view is active', () => {
renderRightPanel({
isInitView: true,
displayVersion: null,
displayOutputData: null,
versionOptions: [],
currentVersionLabel: '',
})
expect(screen.getByText('workflow.nodes.tool.contextGenerate.rightSidePlaceholder')).toBeInTheDocument()
})
it('should allow selecting versions and invoke run, apply, close, and resize actions', () => {
const { props } = renderRightPanel()
fireEvent.click(screen.getByRole('button', { name: 'Version 2' }))
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.tool.contextGenerate.run' }))
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.tool.contextGenerate.apply' }))
fireEvent.pointerDown(screen.getByRole('button', { name: 'workflow.nodes.tool.contextGenerate.resizeHandle' }))
expect(props.onSelectVersion).toHaveBeenCalledWith(1)
expect(props.onRun).toHaveBeenCalledTimes(1)
expect(props.onApply).toHaveBeenCalledTimes(1)
expect(props.onResizeStart).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('code-editor-javascript')).toHaveTextContent('return input')
expect(screen.getByTestId('code-editor-json')).toHaveTextContent('"result"')
})
it('should show a running badge instead of the run button when execution is in progress', () => {
renderRightPanel({
isRunning: true,
})
expect(screen.getByText('workflow.nodes.tool.contextGenerate.running')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'workflow.nodes.tool.contextGenerate.run' })).not.toBeInTheDocument()
})
it('should render loading and close actions in init mode before any version is available', () => {
const { props } = renderRightPanel({
isInitView: true,
isGenerating: true,
displayVersion: null,
displayOutputData: null,
versionOptions: [{ index: 0, label: 'Version 1' }],
})
fireEvent.click(screen.getAllByRole('button')[0]!)
expect(screen.getByTestId('loading')).toBeInTheDocument()
expect(props.onClose).toHaveBeenCalledTimes(1)
})
it('should toggle the version menu only when multiple versions exist and use fallback code/output values', () => {
renderRightPanel({
displayVersion: {
...defaultVersion,
code: '',
},
displayOutputData: null,
versionOptions: [{ index: 0, label: 'Version 1' }],
})
fireEvent.click(screen.getByRole('button', { name: 'portal-open' }))
fireEvent.click(screen.getAllByText('Version 1')[1]!)
fireEvent.click(screen.getByRole('button', { name: 'portal-close' }))
expect(screen.queryByTestId('code-editor-javascript')).toHaveTextContent('')
expect(screen.getByTestId('code-editor-json')).toHaveTextContent('"variables":[]')
expect(screen.queryByText('Version 2')).not.toBeInTheDocument()
})
it('should open and toggle the version menu when multiple versions exist', () => {
const { props } = renderRightPanel()
fireEvent.click(screen.getByRole('button', { name: 'portal-open' }))
fireEvent.click(screen.getAllByText('Version 1')[0]!)
fireEvent.click(screen.getByRole('button', { name: 'Version 2' }))
expect(props.onSelectVersion).toHaveBeenCalledWith(1)
})
})

View File

@ -0,0 +1,99 @@
import type { ContextGenerateResponse } from '@/service/debug'
import { act, renderHook, waitFor } from '@testing-library/react'
import useContextGenData from '../use-context-gen-data'
const createVersion = (code: string): ContextGenerateResponse => ({
variables: [{ variable: 'result', value_selector: ['result'] }],
outputs: { result: { type: 'string' } },
code,
code_language: 'python3',
message: '',
error: '',
})
describe('useContextGenData', () => {
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
})
it('should append versions and select the latest one by default', async () => {
const { result } = renderHook(() => useContextGenData({ storageKey: 'tool-query' }))
await act(async () => {
result.current.addVersion(createVersion('print(1)'))
})
await waitFor(() => {
expect(result.current.versions).toHaveLength(1)
expect(result.current.currentVersionIndex).toBe(0)
expect(result.current.current?.code).toBe('print(1)')
})
await act(async () => {
result.current.addVersion(createVersion('print(2)'))
})
await waitFor(() => {
expect(result.current.versions).toHaveLength(2)
expect(result.current.currentVersionIndex).toBe(1)
expect(result.current.current?.code).toBe('print(2)')
})
})
it('should allow switching versions and clearing persisted state', async () => {
const { result } = renderHook(() => useContextGenData({ storageKey: 'tool-query' }))
await act(async () => {
result.current.addVersion(createVersion('print(1)'))
})
await act(async () => {
result.current.addVersion(createVersion('print(2)'))
})
await act(async () => {
result.current.setCurrentVersionIndex(0)
})
await waitFor(() => {
expect(result.current.current?.code).toBe('print(1)')
})
await act(async () => {
result.current.clearVersions()
})
await waitFor(() => {
expect(result.current.versions).toEqual([])
expect(result.current.currentVersionIndex).toBe(0)
expect(result.current.current).toBeUndefined()
})
})
it('should append into an empty persisted list when the setter receives an undefined previous value', async () => {
vi.resetModules()
const setVersions = vi.fn()
const setCurrentVersionIndex = vi.fn()
vi.doMock('ahooks', () => ({
useSessionStorageState: (key: string) => {
if (key.endsWith('versions'))
return [undefined, setVersions]
return [0, setCurrentVersionIndex]
},
}))
const { default: useContextGenDataWithMock } = await import('../use-context-gen-data')
const { result } = renderHook(() => useContextGenDataWithMock({ storageKey: 'tool-query' }))
const version = createVersion('print(3)')
act(() => {
result.current.addVersion(version)
})
const updater = setVersions.mock.calls[0]?.[0] as ((prev?: ContextGenerateResponse[]) => ContextGenerateResponse[])
expect(updater(undefined)).toEqual([version])
vi.resetModules()
})
})

View File

@ -0,0 +1,951 @@
import type { ToolParameter } from '@/app/components/tools/types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
import { act, renderHook, waitFor } from '@testing-library/react'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import useContextGenerate, {
buildValueSelector,
mapCodeNodeOutputs,
mapCodeNodeVariables,
normalizeCodeLanguage,
resolveVarSchema,
toAvailableVarsPayload,
} from '../use-context-generate'
const {
mockFetchContextGenerateSuggestedQuestions,
mockGenerateContext,
mockStorageGet,
mockStorageSet,
mockToastError,
mockDefaultModelState,
mockAvailableVarHookState,
mockWorkflowNodesState,
mockLocaleState,
} = vi.hoisted(() => ({
mockFetchContextGenerateSuggestedQuestions: vi.fn(),
mockGenerateContext: vi.fn(),
mockStorageGet: vi.fn(),
mockStorageSet: vi.fn(),
mockToastError: vi.fn(),
mockDefaultModelState: {
value: {
model: 'gpt-4o',
provider: {
provider: 'openai',
},
},
} as {
value: {
model: string
provider: {
provider: string
}
} | null
},
mockAvailableVarHookState: {
value: {
availableVars: [] as NodeOutPutVar[],
availableNodesWithParent: [] as Node[],
},
},
mockWorkflowNodesState: {
value: [] as Array<{ id: string, data: { paramSchemas?: ToolParameter[] } }>,
},
mockLocaleState: {
value: 'en_US',
},
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
defaultModel: mockDefaultModelState.value,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
default: (_toolNodeId: string, options?: { filterVar?: (value: unknown) => boolean }) => {
options?.filterVar?.({})
return mockAvailableVarHookState.value
},
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { nodes: Array<{ id: string, data: { paramSchemas?: ToolParameter[] } }> }) => unknown) =>
selector({
nodes: mockWorkflowNodesState.value,
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => mockLocaleState.value,
}))
vi.mock('@/service/debug', async () => {
const actual = await vi.importActual<typeof import('@/service/debug')>('@/service/debug')
return {
...actual,
fetchContextGenerateSuggestedQuestions: (...args: unknown[]) => mockFetchContextGenerateSuggestedQuestions(...args),
generateContext: (...args: unknown[]) => mockGenerateContext(...args),
}
})
vi.mock('@/utils/storage', () => ({
storage: {
get: (...args: unknown[]) => mockStorageGet(...args),
set: (...args: unknown[]) => mockStorageSet(...args),
},
}))
const availableVars: NodeOutPutVar[] = [{
nodeId: 'start-node',
title: 'Start',
vars: [{
variable: 'result',
type: VarType.string,
children: undefined,
}],
}]
const availableNodes: Node[] = [{
id: 'start-node',
position: { x: 0, y: 0 },
data: {
title: 'Start',
type: 'start',
},
} as unknown as Node]
const codeNodeData = {
title: 'Context Extractor',
desc: '',
type: BlockEnum.Code,
code_language: CodeLanguage.python3,
code: 'print(result)',
outputs: {
result: {
type: VarType.string,
children: null,
},
},
variables: [{
variable: 'result',
value_selector: ['result'],
}],
} as unknown as CodeNodeType
const buildCodeNodeData = (overrides: Partial<CodeNodeType> = {}): CodeNodeType => ({
...codeNodeData,
...overrides,
}) as CodeNodeType
describe('useContextGenerate', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
mockDefaultModelState.value = {
model: 'gpt-4o',
provider: {
provider: 'openai',
},
}
mockAvailableVarHookState.value = {
availableVars: [],
availableNodesWithParent: [],
}
mockWorkflowNodesState.value = [{
id: 'tool-node',
data: {
paramSchemas: [{
name: 'query',
type: 'string',
required: true,
llm_description: 'Query string',
human_description: { en_US: 'Query string' },
label: { en_US: 'Query' },
} as ToolParameter],
},
}]
mockLocaleState.value = 'en_US'
mockStorageGet.mockReturnValue(null)
mockFetchContextGenerateSuggestedQuestions.mockImplementation((_payload, registerAbort?: (abortController: AbortController) => void) => {
const controller = new AbortController()
registerAbort?.(controller)
return Promise.resolve({
questions: ['How do I validate the payload?'],
})
})
mockGenerateContext.mockResolvedValue({
variables: [{ variable: 'result', value_selector: ['result'] }],
outputs: { result: { type: 'string' } },
code_language: CodeLanguage.javascript,
code: 'return result',
message: 'Generated version',
error: '',
})
})
it('should normalize helper inputs used by the hook payload builders', () => {
expect(normalizeCodeLanguage('javascript')).toBe(CodeLanguage.javascript)
expect(normalizeCodeLanguage('unknown')).toBe(CodeLanguage.python3)
expect(buildValueSelector('', {
variable: 'foo.bar',
type: VarType.string,
children: undefined,
})).toEqual(['foo', 'bar'])
expect(buildValueSelector('start-node', {
variable: 'sys.query',
type: VarType.string,
children: undefined,
})).toEqual(['sys', 'query'])
expect(buildValueSelector('node-1', {
variable: 'result',
type: VarType.string,
children: undefined,
})).toEqual(['node-1', 'result'])
expect(buildValueSelector('node-1', {
variable: 'retrieval.docs',
type: VarType.string,
children: undefined,
isRagVariable: true,
} as Parameters<typeof buildValueSelector>[1])).toEqual(['retrieval', 'docs'])
expect(resolveVarSchema({
variable: 'plain',
type: VarType.string,
children: undefined,
})).toBeUndefined()
expect(resolveVarSchema({
variable: 'array',
type: VarType.arrayObject,
children: [],
} as Parameters<typeof resolveVarSchema>[0])).toBeUndefined()
expect(resolveVarSchema({
variable: 'missing-schema',
type: VarType.object,
children: {},
} as Parameters<typeof resolveVarSchema>[0])).toBeUndefined()
expect(resolveVarSchema({
variable: 'invalid-schema',
type: VarType.object,
children: { schema: '{bad json' },
} as Parameters<typeof resolveVarSchema>[0])).toBeUndefined()
expect(resolveVarSchema({
variable: 'null-schema',
type: VarType.object,
children: { schema: null },
} as unknown as Parameters<typeof resolveVarSchema>[0])).toBeUndefined()
expect(resolveVarSchema({
variable: 'string-schema',
type: VarType.object,
children: { schema: '{"type":"object"}' },
} as Parameters<typeof resolveVarSchema>[0])).toEqual({ type: 'object' })
expect(resolveVarSchema({
variable: 'object-schema',
type: VarType.object,
children: { schema: { type: 'array' } },
} as Parameters<typeof resolveVarSchema>[0])).toEqual({ type: 'array' })
expect(mapCodeNodeOutputs()).toBeUndefined()
expect(mapCodeNodeOutputs({
firstOnly: null as unknown as { type: string },
})).toBeUndefined()
expect(mapCodeNodeOutputs({
first: { type: 'string' },
second: null as unknown as { type: string },
})).toEqual({ first: { type: 'string' } })
expect(mapCodeNodeVariables()).toBeUndefined()
expect(mapCodeNodeVariables([
{ variable: 'foo', value_selector: null },
{ variable: 'bar', value_selector: ['bar'] },
])).toEqual([
{ variable: 'foo', value_selector: [] },
{ variable: 'bar', value_selector: ['bar'] },
])
})
it('should build available var payloads from node metadata and descriptions', () => {
const payload = toAvailableVarsPayload([{
nodeId: 'node-1',
title: 'Node 1',
vars: [
{
variable: 'result',
type: VarType.string,
children: { schema: '{"type":"string"}' },
des: 'legacy description',
} as NodeOutPutVar['vars'][number],
{
variable: 'custom',
type: VarType.string,
children: undefined,
description: 'new description',
} as NodeOutPutVar['vars'][number],
],
}], new Map([
['node-1', { id: 'node-1', data: { type: BlockEnum.Code } } as Node],
]))
expect(payload).toEqual([
expect.objectContaining({
value_selector: ['node-1', 'result'],
description: 'legacy description',
schema: { type: 'string' },
node_type: BlockEnum.Code,
}),
expect.objectContaining({
value_selector: ['node-1', 'custom'],
description: 'new description',
}),
])
})
it('should fetch suggested questions once and allow aborting the request controller', async () => {
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
await act(async () => {
await result.current.handleFetchSuggestedQuestions()
})
await waitFor(() => {
expect(result.current.suggestedQuestions).toEqual(['How do I validate the payload?'])
expect(result.current.hasFetchedSuggestions).toBe(true)
})
await act(async () => {
await result.current.handleFetchSuggestedQuestions()
})
expect(mockFetchContextGenerateSuggestedQuestions).toHaveBeenCalledTimes(1)
expect(() => {
result.current.abortSuggestedQuestions()
}).not.toThrow()
})
it('should append prompt messages and create a new generated version', async () => {
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
act(() => {
result.current.setInputValue('Generate a better context extractor')
})
await act(async () => {
await result.current.handleGenerate()
})
await waitFor(() => {
expect(result.current.promptMessages).toHaveLength(2)
expect(result.current.promptMessages[0].content).toBe('Generate a better context extractor')
expect(result.current.promptMessages[1].content).toBe('Generated version')
expect(result.current.versions).toHaveLength(1)
expect(result.current.current?.code).toBe('return result')
})
})
it('should persist model overrides when the selected model or params change', () => {
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
act(() => {
result.current.handleModelChange({
modelId: 'claude-3-7-sonnet',
provider: 'anthropic',
mode: Type.string,
})
})
act(() => {
result.current.handleCompletionParamsChange({
temperature: 0.2,
})
})
expect(mockStorageSet).toHaveBeenNthCalledWith(1, expect.any(String), expect.objectContaining({
provider: 'anthropic',
name: 'claude-3-7-sonnet',
}))
expect(mockStorageSet).toHaveBeenNthCalledWith(2, expect.any(String), expect.objectContaining({
completion_params: {
temperature: 0.2,
},
}))
expect(result.current.model.provider).toBe('anthropic')
})
it('should derive available vars and prefer the stored model override when explicit values are absent', () => {
mockStorageGet.mockReturnValue({
provider: 'anthropic',
name: 'claude-3-7-sonnet',
mode: 'chat',
completion_params: {
temperature: 0.1,
},
})
mockAvailableVarHookState.value = {
availableVars,
availableNodesWithParent: availableNodes,
}
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
}))
expect(result.current.model).toEqual(expect.objectContaining({
provider: 'anthropic',
name: 'claude-3-7-sonnet',
completion_params: {
temperature: 0.1,
},
}))
expect(result.current.availableVars).toEqual(availableVars)
expect(result.current.availableNodes).toEqual(availableNodes)
expect(result.current.currentVersionLabel).toBe('appDebug.generate.version 1')
})
it('should skip fetching suggestions when ids or model configuration are missing', async () => {
const { result: missingIdsResult } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: '',
paramKey: '',
codeNodeData,
availableVars,
availableNodes,
}))
await act(async () => {
await missingIdsResult.current.handleFetchSuggestedQuestions()
})
expect(mockFetchContextGenerateSuggestedQuestions).not.toHaveBeenCalled()
mockDefaultModelState.value = null
const { result: missingModelResult } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
await act(async () => {
await missingModelResult.current.handleFetchSuggestedQuestions()
})
expect(mockFetchContextGenerateSuggestedQuestions).not.toHaveBeenCalled()
})
it('should pass parameter info and available vars when fetching suggestions and allow force refetch', async () => {
mockAvailableVarHookState.value = {
availableVars,
availableNodesWithParent: availableNodes,
}
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
}))
await act(async () => {
await result.current.handleFetchSuggestedQuestions()
})
await act(async () => {
await result.current.handleFetchSuggestedQuestions({ force: true })
})
expect(mockFetchContextGenerateSuggestedQuestions).toHaveBeenCalledTimes(2)
expect(mockFetchContextGenerateSuggestedQuestions).toHaveBeenNthCalledWith(1, expect.objectContaining({
language: 'English',
available_vars: [{
value_selector: ['start-node', 'result'],
type: VarType.string,
node_id: 'start-node',
node_title: 'Start',
node_type: 'start',
schema: undefined,
}],
parameter_info: expect.objectContaining({
name: 'query',
type: 'string',
description: 'Query string',
label: 'Query',
}),
}), expect.any(Function))
})
it('should surface suggestion request errors and ignore aborted requests', async () => {
mockFetchContextGenerateSuggestedQuestions.mockResolvedValueOnce({
error: 'network-error',
questions: [],
})
const { result, rerender } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
await act(async () => {
await result.current.handleFetchSuggestedQuestions()
})
expect(mockToastError).toHaveBeenCalledWith('pluginTrigger.modal.errors.networkError')
expect(result.current.suggestedQuestions).toEqual([])
expect(result.current.hasFetchedSuggestions).toBe(false)
mockFetchContextGenerateSuggestedQuestions.mockImplementationOnce(async () => {
throw new Error('AbortError')
})
rerender()
await act(async () => {
await result.current.handleFetchSuggestedQuestions()
})
expect(mockToastError).toHaveBeenCalledTimes(1)
})
it('should handle unexpected suggestion failures by clearing questions and toasting', async () => {
mockFetchContextGenerateSuggestedQuestions.mockImplementationOnce(async () => {
throw new Error('boom')
})
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
await act(async () => {
await result.current.handleFetchSuggestedQuestions()
})
expect(mockToastError).toHaveBeenCalledWith('pluginTrigger.modal.errors.networkError')
expect(result.current.suggestedQuestions).toEqual([])
expect(result.current.hasFetchedSuggestions).toBe(false)
})
it('should no-op generate when the input is empty or when required ids are missing', async () => {
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
await act(async () => {
await result.current.handleGenerate()
})
expect(mockGenerateContext).not.toHaveBeenCalled()
const { result: missingIdsResult } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: '',
paramKey: '',
codeNodeData,
availableVars,
availableNodes,
}))
act(() => {
missingIdsResult.current.setInputValue('Generate code')
})
await act(async () => {
await missingIdsResult.current.handleGenerate()
})
expect(mockGenerateContext).not.toHaveBeenCalled()
})
it('should toast generate errors without appending a new version', async () => {
mockGenerateContext.mockResolvedValueOnce({
variables: [],
outputs: {},
code_language: CodeLanguage.python3,
code: '',
message: '',
error: 'generation failed',
})
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
act(() => {
result.current.setInputValue('Generate a better extractor')
})
await act(async () => {
await result.current.handleGenerate()
})
expect(mockToastError).toHaveBeenCalledWith('generation failed')
expect(result.current.promptMessages).toEqual([
expect.objectContaining({
role: 'user',
content: 'Generate a better extractor',
}),
])
expect(result.current.versions).toEqual([])
})
it('should fall back to the default assistant message and reset history when idle', async () => {
mockGenerateContext.mockResolvedValueOnce({
variables: [{ variable: 'result', value_selector: ['result'] }],
outputs: { result: { type: 'string' } },
code_language: CodeLanguage.javascript,
code: 'return result',
message: '',
error: '',
})
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
act(() => {
result.current.setInputValue('Generate with default message')
})
await act(async () => {
await result.current.handleGenerate()
})
await waitFor(() => {
expect(result.current.promptMessages[1].content).toBe('workflow.nodes.tool.contextGenerate.defaultAssistantMessage')
expect(result.current.current?.code).toBe('return result')
expect(mockGenerateContext).toHaveBeenCalledWith(expect.objectContaining({
language: CodeLanguage.python3,
code_context: {
code: 'print(result)',
outputs: { result: { type: VarType.string } },
variables: [{ variable: 'result', value_selector: ['result'] }],
},
}))
})
act(() => {
result.current.handleReset()
})
expect(result.current.promptMessages).toEqual([])
expect(result.current.versions).toEqual([])
expect(result.current.inputValue).toBe('')
})
it('should expose a blank chat model when neither a default model nor an override exists', () => {
mockDefaultModelState.value = null
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData: buildCodeNodeData({
outputs: undefined,
variables: undefined,
}),
availableVars,
availableNodes,
}))
expect(result.current.model).toEqual(expect.objectContaining({
name: '',
provider: '',
}))
})
it('should fall back to default parameter metadata and derived vars when explicit arrays are empty', async () => {
mockWorkflowNodesState.value = [{
id: 'tool-node',
data: {
paramSchemas: [{
name: 'other',
type: 'number',
} as ToolParameter],
},
}]
mockAvailableVarHookState.value = {
availableVars,
availableNodesWithParent: availableNodes,
}
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData: buildCodeNodeData({
code: '',
outputs: undefined,
variables: undefined,
}),
availableVars: [],
availableNodes: [],
}))
await act(async () => {
await result.current.handleFetchSuggestedQuestions({ force: true })
})
expect(mockFetchContextGenerateSuggestedQuestions).toHaveBeenCalledWith(expect.objectContaining({
available_vars: expect.any(Array),
parameter_info: expect.objectContaining({
name: 'query',
type: 'string',
description: '',
}),
}), expect.any(Function))
expect(result.current.availableVars).toEqual(availableVars)
expect(result.current.availableNodes).toEqual(availableNodes)
})
it('should preserve history during an in-flight generation and allow resetting fetched suggestions', async () => {
let resolveGenerate: ((value: Awaited<ReturnType<typeof mockGenerateContext>>) => void) | null = null
mockGenerateContext.mockImplementationOnce(() => new Promise((resolve) => {
resolveGenerate = resolve as typeof resolveGenerate
}))
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
act(() => {
result.current.setInputValue('Generate and keep state')
})
await act(async () => {
void result.current.handleGenerate()
})
act(() => {
result.current.handleReset()
result.current.resetSuggestions()
})
expect(result.current.promptMessages).toEqual([
expect.objectContaining({
role: 'user',
content: 'Generate and keep state',
}),
])
expect(result.current.hasFetchedSuggestions).toBe(false)
await act(async () => {
resolveGenerate?.({
variables: [],
outputs: {},
code_language: CodeLanguage.javascript,
code: '',
message: 'done',
error: '',
})
})
})
it('should derive parameter metadata from locale fallbacks and restore empty completion params from storage', async () => {
mockStorageGet.mockReturnValue({
provider: 'anthropic',
name: 'claude-3-7-sonnet',
mode: 'chat',
})
mockWorkflowNodesState.value = [{
id: 'tool-node',
data: {
paramSchemas: [{
name: 'query',
type: '',
llm_description: '',
human_description: { en_US: 'Fallback description' },
label: { en_US: 'Fallback label' },
options: [{ value: 'a' }, { value: 'b' }],
min: 1,
max: 3,
multiple: true,
} as ToolParameter],
},
}]
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
await act(async () => {
await result.current.handleFetchSuggestedQuestions({ force: true })
})
expect(result.current.model).toEqual(expect.objectContaining({
completion_params: {},
}))
expect(mockFetchContextGenerateSuggestedQuestions).toHaveBeenCalledWith(expect.objectContaining({
parameter_info: expect.objectContaining({
name: 'query',
type: 'string',
description: 'Fallback description',
label: 'Fallback label',
options: ['a', 'b'],
min: 1,
max: 3,
multiple: true,
}),
}), expect.any(Function))
})
it('should fall back to empty descriptions, empty param keys, empty types, and en-US labels when locale-specific values are missing', () => {
mockLocaleState.value = 'ja_JP'
mockWorkflowNodesState.value = [{
id: 'tool-node',
data: {
paramSchemas: [{
name: '',
type: '',
llm_description: '',
human_description: {},
label: { en_US: 'Fallback label' },
} as ToolParameter],
},
}]
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-empty',
toolNodeId: 'tool-node',
paramKey: '',
codeNodeData,
availableVars,
availableNodes,
}))
expect(result.current.promptMessages).toEqual([])
expect(result.current.model).toEqual(expect.objectContaining({
provider: 'openai',
name: 'gpt-4o',
}))
})
it('should build non-latest version labels and tolerate empty suggested-question payloads and zero timestamps', async () => {
vi.spyOn(Date, 'now')
.mockReturnValueOnce(0)
.mockReturnValueOnce(0)
.mockReturnValueOnce(10)
.mockReturnValue(10)
mockFetchContextGenerateSuggestedQuestions.mockResolvedValueOnce({})
const generateResponses = [
{
variables: [],
outputs: {},
code_language: CodeLanguage.javascript,
code: 'first',
message: 'first message',
error: '',
},
{
variables: [],
outputs: {},
code_language: CodeLanguage.javascript,
code: 'second',
message: 'second message',
error: '',
},
]
mockGenerateContext.mockImplementation(() => Promise.resolve(generateResponses.shift()))
const { result } = renderHook(() => useContextGenerate({
storageKey: 'flow-1-tool-node-query',
toolNodeId: 'tool-node',
paramKey: 'query',
codeNodeData,
availableVars,
availableNodes,
}))
await act(async () => {
await result.current.handleFetchSuggestedQuestions({ force: true })
})
act(() => {
result.current.setInputValue('first')
})
await act(async () => {
await result.current.handleGenerate()
})
act(() => {
result.current.setInputValue('second')
})
await act(async () => {
await result.current.handleGenerate()
})
expect(result.current.suggestedQuestions).toEqual([])
expect(result.current.versionOptions[0]?.label).toBe('appDebug.generate.version 1')
expect(result.current.versionOptions[1]?.label).toContain('appDebug.generate.latest')
expect(result.current.promptMessages[1]).toEqual(expect.objectContaining({
durationMs: 0,
}))
vi.restoreAllMocks()
})
})

View File

@ -0,0 +1,99 @@
import { act, renderHook } from '@testing-library/react'
import useResizablePanels from '../use-resizable-panels'
const {
mockUseEventListener,
mockUseSize,
} = vi.hoisted(() => ({
mockUseEventListener: vi.fn(),
mockUseSize: vi.fn(),
}))
const listeners: Partial<Record<'mousemove' | 'mouseup', (event?: { clientY: number }) => void>> = {}
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
return {
...actual,
useEventListener: (eventName: 'mousemove' | 'mouseup', handler: (event?: { clientY: number }) => void) => {
listeners[eventName] = handler
mockUseEventListener(eventName, handler)
},
useSize: (...args: unknown[]) => mockUseSize(...args),
}
})
describe('useResizablePanels', () => {
beforeEach(() => {
vi.clearAllMocks()
listeners.mousemove = undefined
listeners.mouseup = undefined
mockUseSize.mockReturnValue(undefined)
document.body.style.userSelect = ''
})
it('should keep the default height until the container size is known', () => {
const { result } = renderHook(() => useResizablePanels())
expect(result.current.resolvedCodePanelHeight).toBe(556)
})
it('should clamp the panel height and handle drag interactions', () => {
mockUseSize.mockReturnValue({ height: 400 })
const { result, rerender } = renderHook(() => useResizablePanels())
rerender()
expect(result.current.resolvedCodePanelHeight).toBe(316)
act(() => {
;(result.current.rightContainerRef as { current: { offsetHeight: number } | null }).current = { offsetHeight: 400 }
result.current.handleResizeStart({ clientY: 100 } as React.PointerEvent<HTMLButtonElement>)
})
expect(document.body.style.userSelect).toBe('none')
act(() => {
listeners.mousemove?.({ clientY: 40 })
})
expect(result.current.resolvedCodePanelHeight).toBe(256)
act(() => {
listeners.mousemove?.({ clientY: -200 })
})
expect(result.current.resolvedCodePanelHeight).toBe(80)
act(() => {
listeners.mouseup?.()
})
expect(document.body.style.userSelect).toBe('')
})
it('should ignore move and mouseup events when dragging has not started', () => {
mockUseSize.mockReturnValue({ height: 400 })
const { result } = renderHook(() => useResizablePanels())
act(() => {
listeners.mousemove?.({ clientY: 180 })
listeners.mouseup?.()
})
expect(result.current.resolvedCodePanelHeight).toBe(316)
expect(document.body.style.userSelect).toBe('')
})
it('should ignore mouse moves when the container height is unavailable', () => {
mockUseSize.mockReturnValue({ height: 400 })
const { result } = renderHook(() => useResizablePanels())
act(() => {
result.current.handleResizeStart({ clientY: 100 } as React.PointerEvent<HTMLButtonElement>)
listeners.mousemove?.({ clientY: 200 })
})
expect(result.current.resolvedCodePanelHeight).toBe(316)
expect(document.body.style.userSelect).toBe('none')
})
})

View File

@ -47,7 +47,7 @@ const createChatMessageId = () => {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
const buildValueSelector = (nodeId: string, variable: Var): ValueSelector => {
export const buildValueSelector = (nodeId: string, variable: Var): ValueSelector => {
if (!nodeId)
return variable.variable.split('.')
const isSys = variable.variable.startsWith('sys.')
@ -59,7 +59,7 @@ const buildValueSelector = (nodeId: string, variable: Var): ValueSelector => {
return [nodeId, ...variable.variable.split('.')]
}
const resolveVarSchema = (variable: Var): Record<string, unknown> | undefined => {
export const resolveVarSchema = (variable: Var): Record<string, unknown> | undefined => {
const children = variable.children
if (!children || Array.isArray(children))
return undefined
@ -79,7 +79,7 @@ const resolveVarSchema = (variable: Var): Record<string, unknown> | undefined =>
return schema as Record<string, unknown>
}
const toAvailableVarsPayload = (
export const toAvailableVarsPayload = (
availableVars: NodeOutPutVar[],
nodeMap: Map<string, Node>,
): ContextGenerateAvailableVar[] => {
@ -106,7 +106,7 @@ const toAvailableVarsPayload = (
return results
}
const mapCodeNodeOutputs = (outputs?: Record<string, { type: string } | { type: string, children?: null }>) => {
export const mapCodeNodeOutputs = (outputs?: Record<string, { type: string } | { type: string, children?: null }>) => {
if (!outputs)
return undefined
const next: Record<string, { type: string }> = {}
@ -118,7 +118,7 @@ const mapCodeNodeOutputs = (outputs?: Record<string, { type: string } | { type:
return Object.keys(next).length ? next : undefined
}
const mapCodeNodeVariables = (variables?: Array<{ variable: string, value_selector?: string[] | null }>) => {
export const mapCodeNodeVariables = (variables?: Array<{ variable: string, value_selector?: string[] | null }>) => {
if (!variables)
return undefined
return variables.map(variable => ({

View File

@ -0,0 +1,50 @@
import { STORAGE_KEYS } from '@/config/storage-keys'
import {
buildContextGenStorageKey,
clearContextGenStorage,
CONTEXT_GEN_STORAGE_SUFFIX,
getContextGenStorageKey,
getContextGenStorageKeys,
} from '../storage'
describe('context generate storage helpers', () => {
beforeEach(() => {
sessionStorage.clear()
vi.restoreAllMocks()
})
it('should build a fallback storage key when flow id is missing', () => {
expect(buildContextGenStorageKey(undefined, 'tool-node', 'query')).toBe('unknown-tool-node-query')
})
it('should include the session prefix and suffix in generated keys', () => {
const storageKey = 'flow-1-tool-node-query'
expect(getContextGenStorageKey(storageKey, CONTEXT_GEN_STORAGE_SUFFIX.messages)).toBe(
`${STORAGE_KEYS.SESSION.CONTEXT_GENERATE.PREFIX}${storageKey}-messages`,
)
})
it('should return all managed keys in a stable order', () => {
const storageKey = 'flow-1-tool-node-query'
expect(getContextGenStorageKeys(storageKey)).toEqual([
getContextGenStorageKey(storageKey, CONTEXT_GEN_STORAGE_SUFFIX.versions),
getContextGenStorageKey(storageKey, CONTEXT_GEN_STORAGE_SUFFIX.versionIndex),
getContextGenStorageKey(storageKey, CONTEXT_GEN_STORAGE_SUFFIX.messages),
getContextGenStorageKey(storageKey, CONTEXT_GEN_STORAGE_SUFFIX.suggestedQuestions),
getContextGenStorageKey(storageKey, CONTEXT_GEN_STORAGE_SUFFIX.suggestedQuestionsFetched),
])
})
it('should clear every managed key and ignore remove errors', () => {
const storageKey = 'flow-1-tool-node-query'
const removeItemSpy = vi.spyOn(Object.getPrototypeOf(window.sessionStorage) as Storage, 'removeItem')
.mockImplementationOnce(() => {
throw new Error('private browsing')
})
expect(() => clearContextGenStorage(storageKey)).not.toThrow()
expect(removeItemSpy).toHaveBeenCalledTimes(5)
})
})

View File

@ -0,0 +1,652 @@
import type { AgentNode } from '@/app/components/base/prompt-editor/types'
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { useState } from 'react'
import { VarKindType } from '@/app/components/workflow/nodes/_base/types'
import { BlockEnum } from '@/app/components/workflow/types'
import MixedVariableTextInput from '../index'
const {
mockPromptEditor,
mockEnsureExtractorNode,
mockEnsureAssembleExtractorNode,
mockAssembleExtractorNodeId,
mockRemoveExtractorNode,
mockSyncExtractorPromptFromText,
mockRequestNestedNodeGraph,
mockOnOpenContextGenerateModal,
mockSetControlPromptEditorRerenderKey,
mockClearContextGenStorage,
} = vi.hoisted(() => ({
mockPromptEditor: vi.fn(),
mockEnsureExtractorNode: vi.fn(),
mockEnsureAssembleExtractorNode: vi.fn(() => 'tool-node_ext_query'),
mockAssembleExtractorNodeId: { value: 'tool-node_ext_query' },
mockRemoveExtractorNode: vi.fn(),
mockSyncExtractorPromptFromText: vi.fn(),
mockRequestNestedNodeGraph: vi.fn(),
mockOnOpenContextGenerateModal: vi.fn(),
mockSetControlPromptEditorRerenderKey: vi.fn(),
mockClearContextGenStorage: vi.fn(),
}))
let reactFlowNodes: Array<{ id: string, data: Record<string, unknown> }> = []
let mockStrategyProviders: Array<Record<string, unknown>> = []
let mockNodesMetaDataMap: Record<string, { defaultValue?: Record<string, unknown>, checkValid?: ReturnType<typeof vi.fn> }> = {}
vi.mock('@/service/use-strategy', () => ({
useStrategyProviders: () => ({ data: mockStrategyProviders }),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesMetaData: () => ({
nodesMap: mockNodesMetaDataMap,
}),
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: vi.fn(),
}),
}))
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: (selector: (state: { configsMap: { flowId: string } }) => unknown) => selector({
configsMap: { flowId: 'flow-1' },
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: {
controlPromptEditorRerenderKey: number
setControlPromptEditorRerenderKey: typeof mockSetControlPromptEditorRerenderKey
nodesDefaultConfigs: Record<string, unknown>
}) => unknown) => selector({
controlPromptEditorRerenderKey: 1,
setControlPromptEditorRerenderKey: mockSetControlPromptEditorRerenderKey,
nodesDefaultConfigs: {},
}),
}))
vi.mock('reactflow', () => ({
useNodes: () => reactFlowNodes,
useStoreApi: () => ({
getState: () => ({
getNodes: () => reactFlowNodes,
}),
}),
}))
vi.mock('@/app/components/base/prompt-editor', () => ({
default: (props: {
value: string
onChange: (value: string) => void
workflowVariableBlock: {
onSelectAgent?: (agent: AgentNode) => void
onAssembleVariables?: () => void
show?: boolean
}
editable?: boolean
}) => {
mockPromptEditor(props)
return (
<div>
<div data-testid="prompt-value">{props.value}</div>
<button type="button" onClick={() => props.workflowVariableBlock.onSelectAgent?.({ id: 'agent-1', title: 'Agent One' })}>
select-agent
</button>
<button type="button" onClick={() => props.workflowVariableBlock.onAssembleVariables?.()}>
select-assemble
</button>
<button type="button" onClick={() => props.onChange('plain text')}>
change-text
</button>
<button type="button" onClick={() => props.onChange('{{@agent-1.context@}}updated prompt')}>
change-agent-text
</button>
</div>
)
},
}))
vi.mock('../../context-generate-modal/utils/storage', async () => {
const actual = await vi.importActual<typeof import('../../context-generate-modal/utils/storage')>('../../context-generate-modal/utils/storage')
return {
...actual,
clearContextGenStorage: (...args: unknown[]) => mockClearContextGenStorage(...args),
}
})
vi.mock('../../context-generate-modal', () => ({
__esModule: true,
default: React.forwardRef((props: { isShow: boolean, onOpen?: () => void, onClose?: () => void, onOpenInternalViewAndRun?: () => void }, ref: React.Ref<{ onOpen: () => void }>) => {
React.useImperativeHandle(ref, () => ({
onOpen: () => mockOnOpenContextGenerateModal(),
}))
return props.isShow
? (
<div data-testid="context-generate-modal">
<button type="button" onClick={() => props.onOpenInternalViewAndRun?.()}>context-run-internal</button>
<button type="button" onClick={() => props.onClose?.()}>context-close</button>
</div>
)
: null
}),
}))
vi.mock('../../sub-graph-modal', () => ({
__esModule: true,
default: ({
variant,
isOpen,
onClose,
onPendingSingleRunHandled,
}: {
variant: string
isOpen: boolean
onClose?: () => void
onPendingSingleRunHandled?: () => void
}) => (
isOpen
? (
<div data-testid={`sub-graph-${variant}`}>
<button type="button" onClick={() => onClose?.()}>close-sub-graph</button>
<button type="button" onClick={() => onPendingSingleRunHandled?.()}>handled-sub-graph</button>
</div>
)
: null
),
}))
vi.mock('../components', () => ({
AgentHeaderBar: ({
agentName,
onRemove,
onViewInternals,
hasWarning,
}: {
agentName: string
onRemove: () => void
onViewInternals?: () => void
hasWarning?: boolean
}) => (
<div>
<span>{agentName}</span>
<span>{hasWarning ? 'warning-on' : 'warning-off'}</span>
<button type="button" onClick={onRemove}>remove-header</button>
{onViewInternals && <button type="button" onClick={onViewInternals}>view-header</button>}
</div>
),
Placeholder: () => <div data-testid="placeholder" />,
}))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useMixedVariableExtractor: () => ({
assembleExtractorNodeId: mockAssembleExtractorNodeId.value,
ensureExtractorNode: mockEnsureExtractorNode,
ensureAssembleExtractorNode: mockEnsureAssembleExtractorNode,
removeExtractorNode: mockRemoveExtractorNode,
syncExtractorPromptFromText: mockSyncExtractorPromptFromText,
requestNestedNodeGraph: mockRequestNestedNodeGraph,
}),
}
})
const availableNodes = [
{
id: 'agent-1',
position: { x: 0, y: 0 },
data: {
title: 'Agent One',
type: BlockEnum.Agent,
height: 120,
width: 240,
position: { x: 0, y: 0 },
},
},
] as unknown as Node[]
const nodesOutputVars: NodeOutPutVar[] = []
const renderMixedInput = (overrides: Partial<React.ComponentProps<typeof MixedVariableTextInput>> = {}) => {
const onChange = vi.fn()
reactFlowNodes = [
...availableNodes,
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
result: {
type: 'string',
children: null,
},
},
},
} as unknown as { id: string, data: Record<string, unknown> },
]
const props: React.ComponentProps<typeof MixedVariableTextInput> = {
value: '',
onChange,
toolNodeId: 'tool-node',
paramKey: 'query',
availableNodes,
nodesOutputVars,
...overrides,
}
return {
...render(<MixedVariableTextInput {...props} />),
onChange,
}
}
describe('MixedVariableTextInput', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockAssembleExtractorNodeId.value = 'tool-node_ext_query'
mockStrategyProviders = []
mockNodesMetaDataMap = {}
})
afterEach(() => {
vi.useRealTimers()
})
it('should insert agent context through the slash workflow variable block', () => {
const { onChange } = renderMixedInput()
fireEvent.click(screen.getByRole('button', { name: 'select-agent' }))
expect(mockEnsureExtractorNode).toHaveBeenCalledWith(expect.objectContaining({
extractorNodeId: 'tool-node_ext_query',
nodeType: BlockEnum.LLM,
}))
expect(onChange).toHaveBeenCalledWith(
'{{@agent-1.context@}}',
VarKindType.nested_node,
expect.objectContaining({
extractor_node_id: 'tool-node_ext_query',
output_selector: ['structured_output', 'query'],
}),
)
expect(mockSyncExtractorPromptFromText).toHaveBeenCalledWith('{{@agent-1.context@}}', expect.any(Function))
expect(mockRequestNestedNodeGraph).toHaveBeenCalledWith(expect.objectContaining({
agentId: 'agent-1',
extractorNodeId: 'tool-node_ext_query',
valueText: '{{@agent-1.context@}}',
}))
}, 15000)
it('should switch assemble selections into the context generate flow', () => {
const { onChange } = renderMixedInput()
fireEvent.click(screen.getByRole('button', { name: 'select-assemble' }))
act(() => {
vi.runAllTimers()
})
expect(mockEnsureAssembleExtractorNode).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(
'{{#tool-node_ext_query.result#}}',
VarKindType.nested_node,
expect.objectContaining({
extractor_node_id: 'tool-node_ext_query',
output_selector: ['result'],
}),
)
expect(screen.getByTestId('context-generate-modal')).toBeInTheDocument()
expect(mockOnOpenContextGenerateModal).toHaveBeenCalledTimes(1)
})
it('should remove the extractor node when plain text clears an agent context value', () => {
const { onChange } = renderMixedInput({
value: '{{@agent-1.context@}}prompt body',
})
fireEvent.click(screen.getByRole('button', { name: 'change-text' }))
expect(mockRemoveExtractorNode).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('plain text', VarKindType.mixed, null)
})
it('should open the assemble sub graph when the header action is triggered', () => {
renderMixedInput({
value: '{{#tool-node_ext_query.result#}}',
})
fireEvent.click(screen.getByRole('button', { name: 'view-header' }))
expect(screen.getByTestId('sub-graph-assemble')).toBeInTheDocument()
})
it('should remove agent and assemble header selections and clear persisted context data', () => {
const { onChange, unmount } = renderMixedInput({
value: '{{@agent-1.context@}}',
})
fireEvent.click(screen.getByRole('button', { name: 'remove-header' }))
expect(mockRemoveExtractorNode).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('', VarKindType.mixed, null)
unmount()
renderMixedInput({
value: '{{#tool-node_ext_query.result#}}',
})
fireEvent.click(screen.getByRole('button', { name: 'remove-header' }))
expect(mockClearContextGenStorage).toHaveBeenCalledWith('flow-1-tool-node-query')
})
it('should sync extractor prompts for updated agent text and show the inline agent placeholder', () => {
const { onChange } = renderMixedInput({
value: '{{@agent-1.context@}}',
})
fireEvent.click(screen.getByRole('button', { name: 'change-agent-text' }))
expect(mockSyncExtractorPromptFromText).toHaveBeenCalledWith('{{@agent-1.context@}}updated prompt', expect.any(Function))
expect(onChange).toHaveBeenCalledWith('{{@agent-1.context@}}updated prompt')
expect(screen.getByText(/workflow\.nodes\.tool\.agentPlaceholder/)).toBeInTheDocument()
})
it('should open the agent sub-graph and internal run flow from the context modal', () => {
const { unmount } = renderMixedInput({
value: '{{@agent-1.context@}}',
})
fireEvent.click(screen.getByRole('button', { name: 'view-header' }))
expect(screen.getByTestId('sub-graph-agent')).toBeInTheDocument()
unmount()
const AssembleHarness = () => {
const [text, setText] = useState('')
return (
<MixedVariableTextInput
value={text}
onChange={(nextText) => {
setText(nextText)
}}
toolNodeId="tool-node"
paramKey="query"
availableNodes={availableNodes}
nodesOutputVars={nodesOutputVars}
/>
)
}
render(<AssembleHarness />)
fireEvent.click(screen.getByRole('button', { name: 'select-assemble' }))
act(() => {
vi.runAllTimers()
})
fireEvent.click(screen.getByRole('button', { name: 'context-run-internal' }))
expect(screen.getByTestId('sub-graph-assemble')).toBeInTheDocument()
})
it('should pass editable and variable insertion flags through to the prompt editor', () => {
renderMixedInput({
readOnly: true,
disableVariableInsertion: true,
})
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
editable: false,
workflowVariableBlock: expect.objectContaining({
show: false,
}),
}))
})
it('should fall back cleanly when tool ids, assemble ids, or node matches are missing', () => {
mockAssembleExtractorNodeId.value = ''
renderMixedInput({
value: '{{@missing.context@}}',
toolNodeId: undefined,
paramKey: '',
availableNodes: [{
id: 'start-node',
position: { x: 0, y: 0 },
data: {
title: 'Start',
type: BlockEnum.Start,
height: 120,
width: 240,
position: { x: 0, y: 0 },
},
}] as unknown as Node[],
nodesOutputVars: undefined,
})
fireEvent.click(screen.getByRole('button', { name: 'select-agent' }))
fireEvent.click(screen.getByRole('button', { name: 'select-assemble' }))
expect(mockEnsureExtractorNode).not.toHaveBeenCalled()
expect(mockEnsureAssembleExtractorNode).not.toHaveBeenCalled()
expect(screen.queryByTestId('context-generate-modal')).not.toBeInTheDocument()
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
workflowVariableBlock: expect.objectContaining({
agentNodes: [],
variables: [],
showAssembleVariables: false,
}),
}))
})
it('should compute warnings from validator metadata for agents and assemble extractors', () => {
const checkValid = vi.fn((nodeData: Record<string, unknown>) => ({
errorMessage: nodeData.type === BlockEnum.Agent ? 'agent warning' : '',
}))
mockNodesMetaDataMap = {
[BlockEnum.Agent]: {
checkValid,
},
[BlockEnum.LLM]: {
checkValid: vi.fn(() => ({ errorMessage: '' })),
},
[BlockEnum.Code]: {
checkValid: vi.fn(() => ({ errorMessage: 'assemble warning' })),
},
}
mockStrategyProviders = [{
declaration: {
identity: { name: 'provider-1' },
strategies: [{ identity: { name: 'strategy-1' } }],
},
}]
reactFlowNodes = [
{
id: 'agent-1',
data: {
title: 'Agent One',
type: BlockEnum.Agent,
agent_strategy_provider_name: 'provider-1',
agent_strategy_name: 'strategy-1',
},
} as unknown as { id: string, data: Record<string, unknown> },
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
result: {
type: 'string',
children: null,
},
},
},
} as unknown as { id: string, data: Record<string, unknown> },
]
const { rerender } = render(
<MixedVariableTextInput
value="{{@agent-1.context@}}"
onChange={vi.fn()}
toolNodeId="tool-node"
paramKey="query"
availableNodes={availableNodes}
nodesOutputVars={nodesOutputVars}
/>,
)
expect(screen.getByText('warning-on')).toBeInTheDocument()
expect(checkValid).toHaveBeenCalledWith(expect.objectContaining({
type: BlockEnum.Agent,
}), expect.any(Function), expect.objectContaining({
language: 'en_US',
isReadyForCheckValid: true,
}))
rerender(
<MixedVariableTextInput
value="{{#tool-node_ext_query.result#}}"
onChange={vi.fn()}
toolNodeId="tool-node"
paramKey="query"
availableNodes={availableNodes}
nodesOutputVars={nodesOutputVars}
/>,
)
expect(screen.getByText('warning-on')).toBeInTheDocument()
})
it('should close sub-graph and context modals through the provided callbacks', () => {
const { unmount } = renderMixedInput({
value: '{{#tool-node_ext_query.result#}}',
})
fireEvent.click(screen.getByRole('button', { name: 'view-header' }))
expect(screen.getByTestId('sub-graph-assemble')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'close-sub-graph' }))
expect(screen.queryByTestId('sub-graph-assemble')).not.toBeInTheDocument()
unmount()
renderMixedInput()
fireEvent.click(screen.getByRole('button', { name: 'select-assemble' }))
act(() => {
vi.runAllTimers()
})
expect(screen.getByTestId('context-generate-modal')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'context-close' }))
expect(screen.queryByTestId('context-generate-modal')).not.toBeInTheDocument()
})
it('should clear pending sub-graph run state after the nested modal handles it', () => {
const AssembleHarness = () => {
const [text, setText] = useState('{{#tool-node_ext_query.result#}}')
return (
<MixedVariableTextInput
value={text}
onChange={(nextText) => {
setText(nextText)
}}
toolNodeId="tool-node"
paramKey="query"
availableNodes={availableNodes}
nodesOutputVars={nodesOutputVars}
/>
)
}
render(<AssembleHarness />)
fireEvent.click(screen.getByRole('button', { name: 'view-header' }))
expect(screen.getByTestId('sub-graph-assemble')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'handled-sub-graph' }))
expect(screen.getByTestId('sub-graph-assemble')).toBeInTheDocument()
})
it('should fall back to workflow extractor ids and early-return remove actions when callbacks are absent', () => {
mockAssembleExtractorNodeId.value = ''
renderMixedInput({
value: '{{@agent-1.context@}}',
onChange: undefined,
})
fireEvent.click(screen.getByRole('button', { name: 'remove-header' }))
fireEvent.click(screen.getByRole('button', { name: 'select-agent' }))
expect(mockRemoveExtractorNode).not.toHaveBeenCalled()
expect(mockEnsureExtractorNode).not.toHaveBeenCalled()
})
it('should use the fallback extractor id when opening assemble flows and guard assemble removal without callbacks', () => {
mockAssembleExtractorNodeId.value = ''
reactFlowNodes = [
...availableNodes,
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
result: {
type: 'string',
children: null,
},
},
},
} as unknown as { id: string, data: Record<string, unknown> },
]
render(
<MixedVariableTextInput
value=""
onChange={vi.fn()}
toolNodeId="tool-node"
paramKey="query"
availableNodes={availableNodes}
nodesOutputVars={nodesOutputVars}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'select-assemble' }))
act(() => {
vi.runAllTimers()
})
expect(mockOnOpenContextGenerateModal).toHaveBeenCalledTimes(1)
const { unmount } = renderMixedInput({
value: '{{#tool-node_ext_query.result#}}',
onChange: undefined,
})
fireEvent.click(screen.getByRole('button', { name: 'remove-header' }))
expect(mockClearContextGenStorage).not.toHaveBeenCalled()
unmount()
})
it('should warn when the detected agent is not present in the workflow graph', () => {
mockNodesMetaDataMap = {
[BlockEnum.Agent]: {
checkValid: vi.fn(() => ({ errorMessage: 'agent warning' })),
},
}
reactFlowNodes = []
render(
<MixedVariableTextInput
value="{{@agent-1.context@}}"
onChange={vi.fn()}
toolNodeId={undefined}
paramKey=""
availableNodes={availableNodes}
nodesOutputVars={nodesOutputVars}
/>,
)
expect(screen.getByText('warning-on')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AgentHeaderBar from '../agent-header-bar'
describe('AgentHeaderBar', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the agent tag with the default @ prefix and handle actions', async () => {
const user = userEvent.setup()
const onRemove = vi.fn()
const onViewInternals = vi.fn()
render(
<AgentHeaderBar
agentName="Research Agent"
onRemove={onRemove}
onViewInternals={onViewInternals}
/>,
)
expect(screen.getByText('@Research Agent')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.common.viewInternals' }))
await user.click(screen.getAllByRole('button')[0])
expect(onViewInternals).toHaveBeenCalledTimes(1)
expect(onRemove).toHaveBeenCalledTimes(1)
})
it('should hide the @ prefix for assemble mode and show the warning affordance', () => {
render(
<AgentHeaderBar
agentName="Assemble variables"
onRemove={vi.fn()}
onViewInternals={vi.fn()}
hasWarning
showAtPrefix={false}
/>,
)
expect(screen.getByText('Assemble variables')).toBeInTheDocument()
expect(screen.queryByText('@Assemble variables')).not.toBeInTheDocument()
})
})

View File

@ -83,4 +83,17 @@ describe('Tool mixed variable placeholder', () => {
expect(mockTextNode).toHaveBeenCalledWith('/')
expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, expect.any(FocusEvent))
})
it('should hide slash insertion actions when variable insertion is disabled', () => {
render(<Placeholder disableVariableInsertion />)
expect(screen.queryByText('workflow.nodes.tool.insertPlaceholder2')).not.toBeInTheDocument()
expect(screen.queryByText('/')).not.toBeInTheDocument()
})
it('should hide the type badge when hideBadge is enabled', () => {
render(<Placeholder hideBadge />)
expect(screen.queryByText('String')).not.toBeInTheDocument()
})
})

View File

@ -53,7 +53,7 @@ export const buildAssembleNestedNodeConfig = (
}
}
const resolvePromptText = (item?: PromptItem): string => {
export const resolvePromptText = (item?: PromptItem): string => {
if (!item)
return ''
if (item.edition_type === EditionType.jinja2)
@ -61,7 +61,7 @@ const resolvePromptText = (item?: PromptItem): string => {
return item.text || ''
}
const getUserPromptText = (promptTemplate?: PromptTemplateItem[] | PromptItem): string => {
export const getUserPromptText = (promptTemplate?: PromptTemplateItem[] | PromptItem): string => {
if (!promptTemplate)
return ''
if (Array.isArray(promptTemplate)) {
@ -73,7 +73,7 @@ const getUserPromptText = (promptTemplate?: PromptTemplateItem[] | PromptItem):
return resolvePromptText(promptTemplate)
}
const hasUserPromptTemplate = (promptTemplate: PromptTemplateItem[] | PromptItem): boolean => {
export const hasUserPromptTemplate = (promptTemplate: PromptTemplateItem[] | PromptItem): boolean => {
if (!Array.isArray(promptTemplate))
return true
return promptTemplate.some(item => !isPromptMessageContext(item) && item.role === PromptRole.user)
@ -93,7 +93,7 @@ const applyPromptText = (item: PromptItem, text: string): PromptItem => {
}
}
const buildPromptTemplateWithText = (
export const buildPromptTemplateWithText = (
promptTemplate: PromptTemplateItem[] | PromptItem,
text: string,
): PromptTemplateItem[] | PromptItem => {

View File

@ -0,0 +1,967 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { VarKindType } from '@/app/components/workflow/nodes/_base/types'
import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types'
import {
getSubGraphUserPromptText,
resolveSubGraphAssembleOutputSelector,
resolveSubGraphPromptText,
} from '../helpers'
import SubGraphModal from '../index'
const {
mockSubGraphCanvas,
mockSetNodes,
mockSetControlPromptEditorRerenderKey,
mockHandleSyncWorkflowDraft,
mockDoSyncWorkflowDraft,
mockGetBeforeNodesInSameBranch,
mockGetNodeAvailableVars,
} = vi.hoisted(() => ({
mockSubGraphCanvas: vi.fn(),
mockSetNodes: vi.fn(),
mockSetControlPromptEditorRerenderKey: vi.fn(),
mockHandleSyncWorkflowDraft: vi.fn(),
mockDoSyncWorkflowDraft: vi.fn(),
mockGetBeforeNodesInSameBranch: vi.fn(),
mockGetNodeAvailableVars: vi.fn(),
}))
let workflowNodes = [
{
id: 'tool-node',
data: {
type: BlockEnum.Tool,
tool_parameters: {
query: {
value: '',
type: VarKindType.mixed,
},
},
},
},
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.LLM,
prompt_template: [{ role: PromptRole.user, text: 'existing prompt' }],
},
},
] as Array<Record<string, unknown>>
let mockSavedSubGraphNodes: Array<Record<string, unknown>> | null = null
vi.mock('@headlessui/react', () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogPanel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Transition: ({ children, show }: { children: React.ReactNode, show: boolean }) => show ? <div>{children}</div> : null,
TransitionChild: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: () => workflowNodes,
setNodes: (nextNodes: typeof workflowNodes) => {
workflowNodes = nextNodes
},
}),
}),
useStore: (selector: (state: { edges: unknown[] }) => unknown) => selector({ edges: [] }),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: {
nodes: typeof workflowNodes
setNodes: typeof mockSetNodes
setControlPromptEditorRerenderKey: typeof mockSetControlPromptEditorRerenderKey
}) => unknown) => selector({
nodes: workflowNodes,
setNodes: mockSetNodes,
setControlPromptEditorRerenderKey: mockSetControlPromptEditorRerenderKey,
}),
}))
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: (selector: (state: { configsMap: { flowId: string } }) => unknown) => selector({
configsMap: { flowId: 'flow-1' },
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
useWorkflow: () => ({
getBeforeNodesInSameBranch: mockGetBeforeNodesInSameBranch,
}),
useWorkflowVariables: () => ({
getNodeAvailableVars: mockGetNodeAvailableVars,
}),
useIsChatMode: () => false,
}))
vi.mock('../sub-graph-canvas', () => ({
__esModule: true,
default: (props: {
variant: string
onSave?: (nodes: Array<Record<string, unknown>>) => void
onNestedNodeConfigChange?: (config: { extractor_node_id: string, output_selector: string[], null_strategy: string, default_value: string }) => void
}) => {
mockSubGraphCanvas(props)
const savedNodes = mockSavedSubGraphNodes ?? [
{
id: 'tool-node_ext_query',
data: props.variant === 'agent'
? {
type: BlockEnum.LLM,
prompt_template: [{ role: PromptRole.user, text: 'updated prompt' }],
}
: {
type: BlockEnum.Code,
outputs: {
result: {
type: 'string',
children: null,
},
},
},
},
]
return (
<div data-testid={`sub-graph-canvas-${String(props.variant)}`}>
<button
type="button"
onClick={() => props.onSave?.(savedNodes)}
>
save-sub-graph
</button>
<button
type="button"
onClick={() => props.onNestedNodeConfigChange?.({
extractor_node_id: 'tool-node_ext_query',
output_selector: ['result'],
null_strategy: 'raise-error',
default_value: '',
})}
>
change-nested-config
</button>
</div>
)
},
}))
describe('SubGraphModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSavedSubGraphNodes = null
workflowNodes = [
{
id: 'tool-node',
data: {
type: BlockEnum.Tool,
tool_parameters: {
query: {
value: '',
type: VarKindType.mixed,
},
},
},
},
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.LLM,
prompt_template: [{ role: PromptRole.user, text: 'existing prompt' }],
},
},
] as Array<Record<string, unknown>>
mockGetBeforeNodesInSameBranch.mockReturnValue([{ id: 'start-node', data: { title: 'Start', type: BlockEnum.Start } }])
mockGetNodeAvailableVars.mockReturnValue([{ nodeId: 'start-node', title: 'Start', vars: [] }])
})
it('should normalize prompt and selector helper fallbacks', () => {
expect(resolveSubGraphPromptText()).toBe('')
expect(resolveSubGraphPromptText({
role: PromptRole.user,
text: '',
jinja2_text: '',
edition_type: EditionType.jinja2,
})).toBe('')
expect(getSubGraphUserPromptText()).toBe('')
expect(resolveSubGraphAssembleOutputSelector('fallback', ['fallback'], 'tool-node_ext_query')).toEqual(['fallback'])
expect(resolveSubGraphAssembleOutputSelector(['tool-node_ext_query', 'fallback'], ['fallback'], 'tool-node_ext_query')).toBeNull()
})
it('should render nothing when the modal is closed', () => {
render(
<SubGraphModal
isOpen={false}
onClose={vi.fn()}
variant="assemble"
toolNodeId="tool-node"
paramKey="query"
title="Assemble variables"
/>,
)
expect(screen.queryByTestId('sub-graph-canvas-assemble')).not.toBeInTheDocument()
})
it('should render the agent variant and forward agent-specific props to the sub graph canvas', () => {
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="agent"
toolNodeId="tool-node"
paramKey="query"
sourceVariable={['agent-1', 'context']}
agentName="Agent One"
agentNodeId="agent-1"
pendingSingleRun
onPendingSingleRunHandled={vi.fn()}
/>,
)
expect(screen.getByText(/@Agent One/)).toBeInTheDocument()
expect(screen.getByTestId('sub-graph-canvas-agent')).toBeInTheDocument()
expect(mockSubGraphCanvas).toHaveBeenCalledWith(expect.objectContaining({
variant: 'agent',
sourceVariable: ['agent-1', 'context'],
pendingSingleRun: true,
nestedNodeConfig: expect.objectContaining({
output_selector: ['structured_output', 'query'],
}),
}))
})
it('should render the assemble variant and persist saved extractor output back to the tool node', () => {
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="assemble"
toolNodeId="tool-node"
paramKey="query"
title="Assemble variables"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
const savedToolNode = workflowNodes.find(node => node.id === 'tool-node') as {
data: {
tool_parameters: {
query: {
value: string
type: VarKindType
}
}
}
} | undefined
const savedExtractorNode = workflowNodes.find(node => node.id === 'tool-node_ext_query') as {
hidden?: boolean
} | undefined
expect(screen.getByText(/Assemble variables/)).toBeInTheDocument()
expect(screen.getByTestId('sub-graph-canvas-assemble')).toBeInTheDocument()
expect(savedToolNode?.data.tool_parameters.query.value).toBe('{{#tool-node_ext_query.result#}}')
expect(savedToolNode?.data.tool_parameters.query.type).toBe(VarKindType.nested_node)
expect(savedExtractorNode?.hidden).toBe(true)
expect(mockSetNodes).toHaveBeenCalled()
expect(mockSetControlPromptEditorRerenderKey).toHaveBeenCalled()
}, 15000)
it('should normalize missing nested node config when the tool parameter already uses nested-node mode', () => {
workflowNodes = [
{
id: 'tool-node',
data: {
type: BlockEnum.Tool,
tool_parameters: {
query: {
value: '{{#tool-node_ext_query.result#}}',
type: VarKindType.nested_node,
nested_node_config: {},
},
},
},
},
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
result: {
type: 'string',
children: null,
},
},
},
},
] as Array<Record<string, unknown>>
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="assemble"
toolNodeId="tool-node"
paramKey="query"
title="Assemble variables"
/>,
)
const normalizedToolNode = workflowNodes.find(node => node.id === 'tool-node') as {
data: {
tool_parameters: {
query: {
nested_node_config: {
extractor_node_id: string
output_selector: string[]
default_value: string
}
}
}
}
}
expect(normalizedToolNode.data.tool_parameters.query.nested_node_config).toEqual(expect.objectContaining({
extractor_node_id: 'tool-node_ext_query',
output_selector: ['result'],
default_value: '',
}))
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
})
it('should save agent sub-graphs back into the tool parameter and close from the header button', () => {
const onClose = vi.fn()
render(
<SubGraphModal
isOpen
onClose={onClose}
variant="agent"
toolNodeId="tool-node"
paramKey="query"
sourceVariable={['agent-1', 'context']}
agentName="Agent One"
agentNodeId="agent-1"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
fireEvent.click(screen.getAllByRole('button')[0]!)
const savedToolNode = workflowNodes.find(node => node.id === 'tool-node') as {
data: {
tool_parameters: {
query: {
value: string
type: VarKindType
}
}
}
} | undefined
expect(savedToolNode?.data.tool_parameters.query.value).toBe('{{@agent-1.context@}}updated prompt')
expect(savedToolNode?.data.tool_parameters.query.type).toBe(VarKindType.nested_node)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should fall back to the first assemble output key when result is unavailable', () => {
workflowNodes = [
{
id: 'tool-node',
data: {
type: BlockEnum.Tool,
tool_parameters: {
query: {
value: '{{#tool-node_ext_query.result#}}',
type: VarKindType.nested_node,
nested_node_config: {
extractor_node_id: 'tool-node_ext_query',
output_selector: ['missing'],
null_strategy: 'raise-error',
default_value: '',
},
},
},
},
},
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
fallback: {
type: 'string',
children: null,
},
},
},
},
] as Array<Record<string, unknown>>
mockSavedSubGraphNodes = [{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
fallback: {
type: 'string',
children: null,
},
},
},
}]
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="assemble"
toolNodeId="tool-node"
paramKey="query"
title="Assemble variables"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
const savedToolNode = workflowNodes.find(node => node.id === 'tool-node') as {
data: {
tool_parameters: {
query: {
nested_node_config: {
output_selector: string[]
}
}
}
}
}
expect(savedToolNode.data.tool_parameters.query.nested_node_config.output_selector).toEqual(['fallback'])
})
it('should fall back to the default assemble title and normalize prefixed selectors before passing them to the canvas', () => {
workflowNodes = [
{
id: 'tool-node',
data: {
type: BlockEnum.Tool,
tool_parameters: {
query: {
value: '{{#tool-node_ext_query.result#}}',
type: VarKindType.nested_node,
nested_node_config: {
extractor_node_id: 'tool-node_ext_query',
output_selector: ['tool-node_ext_query', 'result'],
null_strategy: 'raise-error',
default_value: '',
},
},
},
},
},
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
result: {
type: 'string',
children: null,
},
},
},
},
] as Array<Record<string, unknown>>
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="assemble"
toolNodeId="tool-node"
paramKey="query"
title=""
/>,
)
expect(screen.getByText(/workflow\.nodes\.tool\.assembleVariables/)).toBeInTheDocument()
expect(mockSubGraphCanvas).toHaveBeenCalledWith(expect.objectContaining({
nestedNodeConfig: expect.objectContaining({
output_selector: ['result'],
}),
}))
})
it('should ignore nested config changes and save attempts when the tool parameter or extractor node is missing', () => {
workflowNodes = [
{
id: 'tool-node',
data: {
type: BlockEnum.Tool,
tool_parameters: {},
},
},
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {},
},
},
] as Array<Record<string, unknown>>
mockSavedSubGraphNodes = [{
id: 'other-node',
data: {
type: BlockEnum.Code,
},
}]
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="assemble"
toolNodeId="tool-node"
paramKey="query"
title="Assemble variables"
/>,
)
const snapshot = JSON.stringify(workflowNodes)
fireEvent.click(screen.getByRole('button', { name: 'change-nested-config' }))
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
expect(JSON.stringify(workflowNodes)).toBe(snapshot)
})
it('should save agent variants with placeholder-only prompts when no user prompt is present', () => {
mockSavedSubGraphNodes = [{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.LLM,
prompt_template: [{ role: PromptRole.assistant, text: 'assistant only' }],
},
}]
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="agent"
toolNodeId="tool-node"
paramKey="query"
sourceVariable={['agent-1', 'context']}
agentName="Agent One"
agentNodeId="agent-1"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
const savedToolNode = workflowNodes.find(node => node.id === 'tool-node') as {
data: {
tool_parameters: {
query: {
value: string
}
}
}
}
expect(savedToolNode.data.tool_parameters.query.value).toBe('{{@agent-1.context@}}')
})
it('should save agent variants with placeholder-only prompts when the extractor prompt template is missing', () => {
mockSavedSubGraphNodes = [{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.LLM,
},
}]
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="agent"
toolNodeId="tool-node"
paramKey="query"
sourceVariable={['agent-1', 'context']}
agentName="Agent One"
agentNodeId="agent-1"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
expect((workflowNodes.find(node => node.id === 'tool-node') as {
data: { tool_parameters: { query: { value: string } } }
}).data.tool_parameters.query.value).toBe('{{@agent-1.context@}}')
})
it('should inject default assemble outputs when the saved graph does not define any', () => {
workflowNodes = [
{
id: 'tool-node',
data: {
type: BlockEnum.Tool,
tool_parameters: {
query: {
value: '{{#tool-node_ext_query.result#}}',
type: VarKindType.nested_node,
nested_node_config: {
extractor_node_id: 'tool-node_ext_query',
output_selector: ['result'],
null_strategy: 'raise-error',
default_value: '',
},
},
},
},
},
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {},
},
},
] as Array<Record<string, unknown>>
mockSavedSubGraphNodes = [{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {},
},
}]
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="assemble"
toolNodeId="tool-node"
paramKey="query"
title="Assemble variables"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
const savedExtractorNode = workflowNodes.find(node => node.id === 'tool-node_ext_query') as {
data: {
outputs: Record<string, unknown>
}
}
expect(savedExtractorNode.data.outputs).toEqual({
result: {
type: 'string',
children: null,
},
})
})
it('should inject default assemble outputs when the saved graph omits the outputs map entirely', () => {
workflowNodes = [
{
id: 'tool-node',
data: {
type: BlockEnum.Tool,
tool_parameters: {
query: {
value: '{{#tool-node_ext_query.result#}}',
type: VarKindType.nested_node,
nested_node_config: {
extractor_node_id: 'tool-node_ext_query',
output_selector: ['result'],
null_strategy: 'raise-error',
default_value: '',
},
},
},
},
},
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
},
},
] as Array<Record<string, unknown>>
mockSavedSubGraphNodes = [{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
},
}]
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="assemble"
toolNodeId="tool-node"
paramKey="query"
title="Assemble variables"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
expect((workflowNodes.find(node => node.id === 'tool-node_ext_query') as {
data: { outputs: Record<string, unknown> }
}).data.outputs).toEqual({
result: {
type: 'string',
children: null,
},
})
})
it('should handle array prompt templates without user items and jinja prompt fallbacks for agents', () => {
mockSavedSubGraphNodes = [{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.LLM,
prompt_template: [{ role: PromptRole.assistant, text: 'assistant only' }],
},
}]
const { rerender } = render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="agent"
toolNodeId="tool-node"
paramKey="query"
sourceVariable={['agent-1', 'context']}
agentName="Agent One"
agentNodeId="agent-1"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
expect((workflowNodes.find(node => node.id === 'tool-node') as {
data: { tool_parameters: { query: { value: string } } }
}).data.tool_parameters.query.value).toBe('{{@agent-1.context@}}')
mockSavedSubGraphNodes = [{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.LLM,
prompt_template: {
role: PromptRole.user,
text: 'fallback prompt',
jinja2_text: '',
edition_type: EditionType.jinja2,
},
},
}]
rerender(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="agent"
toolNodeId="tool-node"
paramKey="query"
sourceVariable={['agent-1', 'context']}
agentName="Agent One"
agentNodeId="agent-1"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
expect((workflowNodes.find(node => node.id === 'tool-node') as {
data: { tool_parameters: { query: { value: string } } }
}).data.tool_parameters.query.value).toBe('{{@agent-1.context@}}fallback prompt')
})
it('should normalize assemble selectors with prefixed ids and missing extractor ids', () => {
workflowNodes = [
{
id: 'tool-node',
data: {
type: BlockEnum.Tool,
tool_parameters: {
query: {
value: '{{#tool-node_ext_query.result#}}',
type: VarKindType.nested_node,
nested_node_config: {
extractor_node_id: '',
output_selector: ['tool-node_ext_query', 'fallback'],
null_strategy: 'raise-error',
default_value: '',
},
},
},
},
},
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
fallback: {
type: 'string',
children: null,
},
},
},
},
{
id: 'other-node',
data: {
type: BlockEnum.Code,
},
},
] as Array<Record<string, unknown>>
mockSavedSubGraphNodes = [{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
fallback: {
type: 'string',
children: null,
},
},
},
}]
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="assemble"
toolNodeId="tool-node"
paramKey="query"
title="Assemble variables"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
expect((workflowNodes.find(node => node.id === 'tool-node') as {
data: { tool_parameters: { query: { nested_node_config: { extractor_node_id: string, output_selector: string[] } } } }
}).data.tool_parameters.query.nested_node_config).toEqual(expect.objectContaining({
extractor_node_id: 'tool-node_ext_query',
output_selector: ['fallback'],
}))
})
it('should normalize non-array assemble selectors through the save flow', () => {
workflowNodes = [
{
id: 'tool-node',
data: {
type: BlockEnum.Tool,
tool_parameters: {
query: {
value: '{{#tool-node_ext_query.result#}}',
type: VarKindType.nested_node,
nested_node_config: {
extractor_node_id: '',
output_selector: 'fallback',
null_strategy: 'raise-error',
default_value: '',
},
},
},
},
},
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
fallback: {
type: 'string',
children: null,
},
},
},
},
] as Array<Record<string, unknown>>
mockSavedSubGraphNodes = [{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
fallback: {
type: 'string',
children: null,
},
},
},
}]
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="assemble"
toolNodeId="tool-node"
paramKey="query"
title="Assemble variables"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
expect((workflowNodes.find(node => node.id === 'tool-node') as {
data: { tool_parameters: { query: { nested_node_config: { extractor_node_id: string, output_selector: string[] } } } }
}).data.tool_parameters.query.nested_node_config).toEqual(expect.objectContaining({
extractor_node_id: 'tool-node_ext_query',
output_selector: ['fallback'],
}))
})
it('should keep tool nodes unchanged when the target parameter is missing during save', () => {
workflowNodes = [
{
id: 'tool-node',
data: {
type: BlockEnum.Tool,
tool_parameters: {},
},
},
{
id: 'tool-node_ext_query',
data: {
type: BlockEnum.Code,
outputs: {
result: {
type: 'string',
children: null,
},
},
},
},
] as Array<Record<string, unknown>>
render(
<SubGraphModal
isOpen
onClose={vi.fn()}
variant="assemble"
toolNodeId="tool-node"
paramKey="query"
title="Assemble variables"
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'save-sub-graph' }))
expect((workflowNodes.find(node => node.id === 'tool-node') as {
data: { tool_parameters: Record<string, unknown> }
}).data.tool_parameters).toEqual({})
})
})

View File

@ -0,0 +1,54 @@
import { render, screen } from '@testing-library/react'
import { NULL_STRATEGY } from '@/app/components/workflow/nodes/_base/constants'
import { FlowType } from '@/types/common'
import SubGraphCanvas from '../sub-graph-canvas'
const mockSubGraph = vi.fn()
vi.mock('@/app/components/sub-graph', () => ({
default: (props: Record<string, unknown>) => {
mockSubGraph(props)
return <div data-testid="sub-graph" />
},
}))
describe('SubGraphCanvas', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should wrap SubGraph and forward every prop', () => {
render(
<SubGraphCanvas
variant="assemble"
isOpen
toolNodeId="tool-node"
paramKey="query"
pendingSingleRun={false}
onPendingSingleRunHandled={vi.fn()}
title="Assemble variables"
configsMap={{ flowId: 'flow-1', flowType: FlowType.appFlow }}
nestedNodeConfig={{
extractor_node_id: 'tool-node_ext_query',
output_selector: ['result'],
null_strategy: NULL_STRATEGY.RAISE_ERROR,
default_value: '',
}}
onNestedNodeConfigChange={vi.fn()}
extractorNode={undefined}
toolParamValue="{{#tool-node_ext_query.result#}}"
parentAvailableNodes={[]}
parentAvailableVars={[]}
onSave={vi.fn()}
onSyncWorkflowDraft={vi.fn()}
/>,
)
expect(screen.getByTestId('sub-graph')).toBeInTheDocument()
expect(mockSubGraph).toHaveBeenCalledWith(expect.objectContaining({
variant: 'assemble',
toolNodeId: 'tool-node',
paramKey: 'query',
}))
})
})

View File

@ -0,0 +1,41 @@
import type { PromptItem, PromptTemplateItem } from '@/app/components/workflow/types'
import { EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types'
export const resolveSubGraphPromptText = (item?: PromptItem) => {
if (!item)
return ''
if (item.edition_type === EditionType.jinja2)
return item.jinja2_text || item.text || ''
return item.text || ''
}
export const getSubGraphUserPromptText = (promptTemplate?: PromptTemplateItem[] | PromptItem) => {
if (!promptTemplate)
return ''
if (Array.isArray(promptTemplate)) {
for (const item of promptTemplate) {
if (!isPromptMessageContext(item) && item.role === PromptRole.user)
return resolveSubGraphPromptText(item)
}
return ''
}
return resolveSubGraphPromptText(promptTemplate)
}
export const resolveSubGraphAssembleOutputSelector = (
rawSelector: unknown,
outputKeys: string[],
extractorNodeId: string,
) => {
if (outputKeys.length === 0)
return null
const normalizedSelector = Array.isArray(rawSelector)
? (rawSelector[0] === extractorNodeId ? rawSelector.slice(1) : rawSelector)
: []
const currentKey = normalizedSelector[0]
const fallbackKey = outputKeys.includes('result') ? 'result' : outputKeys[0]
const nextKey = outputKeys.includes(currentKey) ? currentKey : fallbackKey
if (!nextKey || nextKey === currentKey)
return null
return [nextKey, ...normalizedSelector.slice(1)]
}

View File

@ -5,7 +5,7 @@ import type { NestedNodeConfig } from '@/app/components/workflow/nodes/_base/typ
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types'
import type { Node, PromptItem, PromptTemplateItem } from '@/app/components/workflow/types'
import type { Node } from '@/app/components/workflow/types'
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
@ -19,7 +19,8 @@ import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { NULL_STRATEGY } from '@/app/components/workflow/nodes/_base/constants'
import { VarKindType } from '@/app/components/workflow/nodes/_base/types'
import { useStore as useWorkflowStore } from '@/app/components/workflow/store'
import { EditionType, isPromptMessageContext, PromptRole, VarType } from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import { getSubGraphUserPromptText, resolveSubGraphAssembleOutputSelector } from './helpers'
import SubGraphCanvas from './sub-graph-canvas'
const SubGraphModal: FC<SubGraphModalProps> = (props) => {
@ -153,26 +154,6 @@ const SubGraphModal: FC<SubGraphModalProps> = (props) => {
handleNestedNodeConfigChange(nestedNodeConfig)
}, [handleNestedNodeConfigChange, nestedNodeConfig, toolParam])
const getUserPromptText = useCallback((promptTemplate?: PromptTemplateItem[] | PromptItem) => {
if (!promptTemplate)
return ''
const resolveText = (item?: PromptItem) => {
if (!item)
return ''
if (item.edition_type === EditionType.jinja2)
return item.jinja2_text || item.text || ''
return item.text || ''
}
if (Array.isArray(promptTemplate)) {
for (const item of promptTemplate) {
if (!isPromptMessageContext(item) && item.role === PromptRole.user)
return resolveText(item)
}
return ''
}
return resolveText(promptTemplate)
}, [])
// TODO: handle external workflow updates while sub-graph modal is open.
const handleSave = useCallback((subGraphNodes: Node[]) => {
const extractorNodeData = subGraphNodes.find(node => node.id === extractorNodeId) as Node<LLMNodeType | CodeNodeType> | undefined
@ -195,22 +176,8 @@ const SubGraphModal: FC<SubGraphModalProps> = (props) => {
}
}
const resolveAssembleOutputSelector = (rawSelector: unknown, outputKeys: string[]) => {
if (outputKeys.length === 0)
return null
const normalizedSelector = Array.isArray(rawSelector)
? (rawSelector[0] === extractorNodeId ? rawSelector.slice(1) : rawSelector)
: []
const currentKey = normalizedSelector[0]
const fallbackKey = outputKeys.includes('result') ? 'result' : outputKeys[0]
const nextKey = outputKeys.includes(currentKey) ? currentKey : fallbackKey
if (!nextKey || nextKey === currentKey)
return null
return [nextKey, ...normalizedSelector.slice(1)]
}
const userPromptText = isAgentVariant
? getUserPromptText((extractorNodeData.data as LLMNodeType).prompt_template)
? getSubGraphUserPromptText((extractorNodeData.data as LLMNodeType).prompt_template)
: ''
const placeholder = isAgentVariant && resolvedAgentNodeId ? `{{@${resolvedAgentNodeId}.context@}}` : ''
const nextValue = isAgentVariant
@ -243,7 +210,7 @@ const SubGraphModal: FC<SubGraphModalProps> = (props) => {
let nextNestedConfig = baseNestedConfig
if (!isAgentVariant) {
const outputKeys = Object.keys((extractorNodeData.data as CodeNodeType).outputs || {})
const nextSelector = resolveAssembleOutputSelector(baseNestedConfig?.output_selector, outputKeys)
const nextSelector = resolveSubGraphAssembleOutputSelector(baseNestedConfig?.output_selector, outputKeys, extractorNodeId)
if (nextSelector) {
nextNestedConfig = {
...baseNestedConfig,
@ -273,7 +240,7 @@ const SubGraphModal: FC<SubGraphModalProps> = (props) => {
setNodes(nextNodes)
setWorkflowNodes(nextNodes)
setControlPromptEditorRerenderKey(Date.now())
}, [assemblePlaceholder, extractorNodeId, getUserPromptText, isAgentVariant, nestedNodeConfig, paramKey, reactflowStore, resolvedAgentNodeId, setControlPromptEditorRerenderKey, setWorkflowNodes, toolNodeId])
}, [assemblePlaceholder, extractorNodeId, isAgentVariant, nestedNodeConfig, paramKey, reactflowStore, resolvedAgentNodeId, setControlPromptEditorRerenderKey, setWorkflowNodes, toolNodeId])
return (
<Transition appear show={isOpen} as={Fragment}>

View File

@ -0,0 +1,57 @@
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import ToolForm from '../index'
const mockToolFormItem = vi.fn()
vi.mock('../item', () => ({
default: (props: Record<string, unknown>) => {
mockToolFormItem(props)
return <div data-testid={`tool-form-item-${String(props.schema && (props.schema as { name?: string }).name)}`} />
},
}))
const createSchema = (name: string): CredentialFormSchema => ({
name,
variable: name,
show_on: [],
type: FormTypeEnum.textInput,
required: false,
default: '',
label: { en_US: name, zh_Hans: name },
} as unknown as CredentialFormSchema)
describe('ToolForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render one form item per schema entry and forward shared props', () => {
render(
<ToolForm
readOnly={false}
nodeId="tool-node"
schema={[createSchema('query'), createSchema('limit')]}
value={{}}
onChange={vi.fn()}
inPanel
showManageInputField
onManageInputField={vi.fn()}
extraParams={{ locale: 'en_US' }}
/>,
)
expect(screen.getByTestId('tool-form-item-query')).toBeInTheDocument()
expect(screen.getByTestId('tool-form-item-limit')).toBeInTheDocument()
expect(mockToolFormItem).toHaveBeenNthCalledWith(1, expect.objectContaining({
readOnly: false,
nodeId: 'tool-node',
providerType: 'tool',
showManageInputField: true,
}))
expect(mockToolFormItem).toHaveBeenNthCalledWith(2, expect.objectContaining({
schema: expect.objectContaining({ name: 'limit' }),
}))
})
})

View File

@ -0,0 +1,167 @@
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import ToolFormItem from '../item'
const mockFormInputItem = vi.fn()
const mockSchemaModal = vi.fn()
let mockLanguage = 'en_US'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => mockLanguage,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent?: React.ReactNode }) => (
<div data-testid="tooltip">{popupContent}</div>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector/components', () => ({
SchemaModal: (props: Record<string, unknown>) => {
mockSchemaModal(props)
return (
<div data-testid="schema-modal">
<span>{String(props.rootName)}</span>
<button type="button" onClick={() => (props.onClose as () => void)?.()}>close-schema</button>
</div>
)
},
}))
vi.mock('@/app/components/workflow/nodes/_base/components/form-input-item', () => ({
default: (props: Record<string, unknown>) => {
mockFormInputItem(props)
return <div data-testid="form-input-item" />
},
}))
const createSchema = (overrides: Partial<CredentialFormSchema> = {}): CredentialFormSchema => ({
name: 'query',
variable: 'query',
show_on: [],
type: FormTypeEnum.textInput,
required: true,
default: '',
label: { en_US: 'Query', zh_Hans: 'Query' },
tooltip: { en_US: 'Prompt description', zh_Hans: 'Prompt description' },
...overrides,
} as unknown as CredentialFormSchema)
describe('ToolFormItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en_US'
})
it('should render required labels, descriptions, and forward props to FormInputItem', () => {
render(
<ToolFormItem
readOnly={false}
nodeId="tool-node"
schema={createSchema()}
value={{}}
onChange={vi.fn()}
inPanel
showManageInputField
onManageInputField={vi.fn()}
extraParams={{ locale: 'en_US' }}
/>,
)
expect(screen.getByText('Query')).toBeInTheDocument()
expect(screen.getByText('*')).toBeInTheDocument()
expect(screen.getByText('Prompt description')).toBeInTheDocument()
expect(screen.getByTestId('form-input-item')).toBeInTheDocument()
expect(mockFormInputItem).toHaveBeenCalledWith(expect.objectContaining({
nodeId: 'tool-node',
providerType: 'tool',
showManageInputField: true,
}))
})
it('should open the schema modal for object-like fields', async () => {
const user = userEvent.setup()
render(
<ToolFormItem
readOnly={false}
nodeId="tool-node"
schema={createSchema({
name: 'payload',
variable: 'payload',
type: FormTypeEnum.object,
required: false,
tooltip: { en_US: 'Payload schema', zh_Hans: 'Payload schema' },
input_schema: { type: Type.object, properties: { name: { type: Type.string } } } as unknown as NonNullable<CredentialFormSchema['input_schema']>,
})}
value={{}}
onChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: /JSON Schema/i }))
expect(screen.getByTestId('schema-modal')).toHaveTextContent('payload')
expect(mockSchemaModal).toHaveBeenCalledWith(expect.objectContaining({
rootName: 'payload',
schema: { type: 'object', properties: { name: { type: 'string' } } },
}))
})
it('should render tooltip content for non-description fields and close the schema modal', async () => {
const user = userEvent.setup()
render(
<ToolFormItem
readOnly={false}
nodeId="tool-node"
schema={createSchema({
label: { en_US: 'Payload', zh_Hans: '' },
type: FormTypeEnum.array,
tooltip: { en_US: 'Array tooltip', zh_Hans: '' },
input_schema: { type: Type.array } as unknown as NonNullable<CredentialFormSchema['input_schema']>,
})}
value={{}}
onChange={vi.fn()}
providerType="trigger"
/>,
)
expect(screen.getByText('Payload')).toBeInTheDocument()
expect(screen.getByTestId('tooltip')).toHaveTextContent('Array tooltip')
expect(screen.queryByText('Prompt description')).not.toBeInTheDocument()
expect(mockFormInputItem).toHaveBeenCalledWith(expect.objectContaining({
providerType: 'trigger',
}))
await user.click(screen.getByRole('button', { name: /JSON Schema/i }))
expect(screen.getByTestId('schema-modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'close-schema' }))
expect(screen.queryByTestId('schema-modal')).not.toBeInTheDocument()
})
it('should fall back to en_US labels and descriptions when the active language entry is missing', () => {
mockLanguage = 'zh_Hans'
render(
<ToolFormItem
readOnly={false}
nodeId="tool-node"
schema={createSchema({
type: FormTypeEnum.secretInput,
label: { en_US: 'Secret Label', zh_Hans: '' },
tooltip: { en_US: 'Secret description', zh_Hans: '' },
})}
value={{}}
onChange={vi.fn()}
/>,
)
expect(screen.getByText('Secret Label')).toBeInTheDocument()
expect(screen.getByText('Secret description')).toBeInTheDocument()
})
})