mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
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:
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 => ({
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 => {
|
||||
|
||||
@ -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({})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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)]
|
||||
}
|
||||
@ -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}>
|
||||
|
||||
@ -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' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user