mirror of
https://github.com/langgenius/dify.git
synced 2026-04-10 19:02:02 +08:00
Compare commits
15 Commits
main
...
test/workf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
228b9994ac | ||
|
|
9e250f3481 | ||
|
|
7fd06f134b | ||
|
|
7430d8c8f1 | ||
|
|
0fe8bafbe9 | ||
|
|
b5ff502a46 | ||
|
|
ffc61b08e8 | ||
|
|
e38ba74b9c | ||
|
|
8907c6787e | ||
|
|
331158e4bf | ||
|
|
9a44d1ec40 | ||
|
|
4d03f4334a | ||
|
|
fbb7642c3b | ||
|
|
756658ed71 | ||
|
|
ca60bb5812 |
@@ -993,7 +993,7 @@ class ToolManager:
|
||||
return {"background": "#252525", "content": "\ud83d\ude01"}
|
||||
|
||||
@classmethod
|
||||
def generate_mcp_tool_icon_url(cls, tenant_id: str, provider_id: str) -> EmojiIconDict | str:
|
||||
def generate_mcp_tool_icon_url(cls, tenant_id: str, provider_id: str) -> EmojiIconDict | dict[str, str] | str:
|
||||
try:
|
||||
with Session(db.engine) as session:
|
||||
mcp_service = MCPToolManageService(session=session)
|
||||
@@ -1001,7 +1001,7 @@ class ToolManager:
|
||||
mcp_provider = mcp_service.get_provider_entity(
|
||||
provider_id=provider_id, tenant_id=tenant_id, by_server_id=True
|
||||
)
|
||||
return cast(EmojiIconDict | str, mcp_provider.provider_icon)
|
||||
return mcp_provider.provider_icon
|
||||
except ValueError:
|
||||
raise ToolProviderNotFoundError(f"mcp provider {provider_id} not found")
|
||||
except Exception:
|
||||
@@ -1013,7 +1013,7 @@ class ToolManager:
|
||||
tenant_id: str,
|
||||
provider_type: ToolProviderType,
|
||||
provider_id: str,
|
||||
) -> str | EmojiIconDict:
|
||||
) -> str | EmojiIconDict | dict[str, str]:
|
||||
"""
|
||||
get the tool icon
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@ class TypeBase(MappedAsDataclass, DeclarativeBase):
|
||||
|
||||
|
||||
class DefaultFieldsMixin:
|
||||
"""Mixin for models that inherit from Base (non-dataclass)."""
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID,
|
||||
primary_key=True,
|
||||
@@ -55,42 +53,6 @@ class DefaultFieldsMixin:
|
||||
return f"<{self.__class__.__name__}(id={self.id})>"
|
||||
|
||||
|
||||
class DefaultFieldsDCMixin(MappedAsDataclass):
|
||||
"""Mixin for models that inherit from TypeBase (MappedAsDataclass)."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID,
|
||||
primary_key=True,
|
||||
insert_default=lambda: str(uuidv7()),
|
||||
default_factory=lambda: str(uuidv7()),
|
||||
init=False,
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
insert_default=naive_utc_now,
|
||||
default_factory=naive_utc_now,
|
||||
init=False,
|
||||
server_default=func.current_timestamp(),
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
insert_default=naive_utc_now,
|
||||
default_factory=naive_utc_now,
|
||||
init=False,
|
||||
server_default=func.current_timestamp(),
|
||||
onupdate=func.current_timestamp(),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}(id={self.id})>"
|
||||
|
||||
|
||||
def gen_uuidv4_string() -> str:
|
||||
"""gen_uuidv4_string generate a UUIDv4 string.
|
||||
|
||||
|
||||
46
web/app/components/workflow/__tests__/block-icon.spec.tsx
Normal file
46
web/app/components/workflow/__tests__/block-icon.spec.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import BlockIcon, { VarBlockIcon } from '../block-icon'
|
||||
import { BlockEnum } from '../types'
|
||||
|
||||
describe('BlockIcon', () => {
|
||||
it('renders the default workflow icon container for regular nodes', () => {
|
||||
const { container } = render(<BlockIcon type={BlockEnum.Start} size="xs" className="extra-class" />)
|
||||
|
||||
const iconContainer = container.firstElementChild
|
||||
expect(iconContainer).toHaveClass('w-4', 'h-4', 'bg-util-colors-blue-brand-blue-brand-500', 'extra-class')
|
||||
expect(iconContainer?.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('normalizes protected plugin icon urls for tool-like nodes', () => {
|
||||
const { container } = render(
|
||||
<BlockIcon
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon="/foo/workspaces/current/plugin/icon/plugin-tool.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
const iconContainer = container.firstElementChild as HTMLElement
|
||||
const backgroundIcon = iconContainer.querySelector('div') as HTMLElement
|
||||
|
||||
expect(iconContainer).not.toHaveClass('bg-util-colors-blue-blue-500')
|
||||
expect(backgroundIcon.style.backgroundImage).toContain(
|
||||
`${API_PREFIX}/workspaces/current/plugin/icon/plugin-tool.png`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('VarBlockIcon', () => {
|
||||
it('renders the compact icon variant without the default container wrapper', () => {
|
||||
const { container } = render(
|
||||
<VarBlockIcon
|
||||
type={BlockEnum.Answer}
|
||||
className="custom-var-icon"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.custom-var-icon')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-util-colors-warning-warning-500')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
39
web/app/components/workflow/__tests__/context.spec.tsx
Normal file
39
web/app/components/workflow/__tests__/context.spec.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { WorkflowContextProvider } from '../context'
|
||||
import { useStore, useWorkflowStore } from '../store'
|
||||
|
||||
const StoreConsumer = () => {
|
||||
const showSingleRunPanel = useStore(s => s.showSingleRunPanel)
|
||||
const store = useWorkflowStore()
|
||||
|
||||
return (
|
||||
<button onClick={() => store.getState().setShowSingleRunPanel(!showSingleRunPanel)}>
|
||||
{showSingleRunPanel ? 'open' : 'closed'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowContextProvider', () => {
|
||||
it('provides the workflow store to descendants and keeps the same store across rerenders', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(
|
||||
<WorkflowContextProvider>
|
||||
<StoreConsumer />
|
||||
</WorkflowContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'closed' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'closed' }))
|
||||
expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<WorkflowContextProvider>
|
||||
<StoreConsumer />
|
||||
</WorkflowContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
67
web/app/components/workflow/__tests__/index.spec.tsx
Normal file
67
web/app/components/workflow/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Edge, Node } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useDatasetsDetailStore } from '../datasets-detail-store/store'
|
||||
import WorkflowWithDefaultContext from '../index'
|
||||
import { BlockEnum } from '../types'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
|
||||
const nodes: Node[] = [
|
||||
{
|
||||
id: 'node-start',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'node-start',
|
||||
target: 'node-end',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
type: 'custom',
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.End,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const ContextConsumer = () => {
|
||||
const { store, shortcutsEnabled } = useWorkflowHistoryStore()
|
||||
const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length)
|
||||
const reactFlowStore = useStoreApi()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{`history:${store.getState().nodes.length}`}
|
||||
{` shortcuts:${String(shortcutsEnabled)}`}
|
||||
{` datasets:${datasetCount}`}
|
||||
{` reactflow:${String(!!reactFlowStore)}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowWithDefaultContext', () => {
|
||||
it('wires the ReactFlow, workflow history, and datasets detail providers around its children', () => {
|
||||
render(
|
||||
<WorkflowWithDefaultContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<ContextConsumer />
|
||||
</WorkflowWithDefaultContext>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('history:1 shortcuts:true datasets:0 reactflow:true'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
|
||||
describe('ShortcutsName', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders mac-friendly key labels and style variants', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Macintosh' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<ShortcutsName
|
||||
keys={['ctrl', 'shift', 's']}
|
||||
bgColor="white"
|
||||
textColor="secondary"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('⌘')).toBeInTheDocument()
|
||||
expect(screen.getByText('⇧')).toBeInTheDocument()
|
||||
expect(screen.getByText('s')).toBeInTheDocument()
|
||||
expect(container.querySelector('.system-kbd')).toHaveClass(
|
||||
'bg-components-kbd-bg-white',
|
||||
'text-text-tertiary',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps raw key names on non-mac systems', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Windows NT' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
render(<ShortcutsName keys={['ctrl', 'alt']} />)
|
||||
|
||||
expect(screen.getByText('ctrl')).toBeInTheDocument()
|
||||
expect(screen.getByText('alt')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Edge, Node } from '../types'
|
||||
import type { WorkflowHistoryState } from '../workflow-history-store'
|
||||
import { render, renderHook, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum } from '../types'
|
||||
import { useWorkflowHistoryStore, WorkflowHistoryProvider } from '../workflow-history-store'
|
||||
|
||||
const nodes: Node[] = [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
selected: true,
|
||||
},
|
||||
selected: true,
|
||||
},
|
||||
]
|
||||
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'node-1',
|
||||
target: 'node-2',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
type: 'custom',
|
||||
selected: true,
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.End,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const HistoryConsumer = () => {
|
||||
const { store, shortcutsEnabled, setShortcutsEnabled } = useWorkflowHistoryStore()
|
||||
|
||||
return (
|
||||
<button onClick={() => setShortcutsEnabled(!shortcutsEnabled)}>
|
||||
{`nodes:${store.getState().nodes.length} shortcuts:${String(shortcutsEnabled)}`}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowHistoryProvider', () => {
|
||||
it('provides workflow history state and shortcut toggles', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<WorkflowHistoryProvider
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<HistoryConsumer />
|
||||
</WorkflowHistoryProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' }))
|
||||
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:false' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sanitizes selected flags when history state is replaced through the exposed store api', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<WorkflowHistoryProvider
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
{children}
|
||||
</WorkflowHistoryProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowHistoryStore(), { wrapper })
|
||||
const nextState: WorkflowHistoryState = {
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
nodes,
|
||||
edges,
|
||||
}
|
||||
|
||||
result.current.store.setState(nextState)
|
||||
|
||||
expect(result.current.store.getState().nodes[0].data.selected).toBe(false)
|
||||
expect(result.current.store.getState().edges[0].selected).toBe(false)
|
||||
})
|
||||
|
||||
it('throws when consumed outside the provider', () => {
|
||||
expect(() => renderHook(() => useWorkflowHistoryStore())).toThrow(
|
||||
'useWorkflowHistoryStoreApi must be used within a WorkflowHistoryProvider',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import AllTools from '../all-tools'
|
||||
import { createGlobalPublicStoreState, createToolProvider } from './factories'
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
useMarketplacePlugins: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
|
||||
useMCPToolAvailability: () => ({
|
||||
allowed: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('@/utils/var')>()),
|
||||
getMarketplaceUrl: () => 'https://marketplace.test/tools',
|
||||
}))
|
||||
|
||||
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
|
||||
const createMarketplacePluginsMock = () => ({
|
||||
plugins: [],
|
||||
total: 0,
|
||||
resetPlugins: vi.fn(),
|
||||
queryPlugins: vi.fn(),
|
||||
queryPluginsWithDebounced: vi.fn(),
|
||||
cancelQueryPluginsWithDebounced: vi.fn(),
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
page: 0,
|
||||
})
|
||||
|
||||
describe('AllTools', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false)))
|
||||
mockUseGetLanguage.mockReturnValue('en_US')
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock())
|
||||
})
|
||||
|
||||
it('filters tools by the active tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<AllTools
|
||||
searchText=""
|
||||
tags={[]}
|
||||
onSelect={vi.fn()}
|
||||
buildInTools={[createToolProvider({
|
||||
id: 'provider-built-in',
|
||||
label: { en_US: 'Built In Provider', zh_Hans: 'Built In Provider' },
|
||||
})]}
|
||||
customTools={[createToolProvider({
|
||||
id: 'provider-custom',
|
||||
type: 'custom',
|
||||
label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' },
|
||||
})]}
|
||||
workflowTools={[]}
|
||||
mcpTools={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Built In Provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom Provider')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.tabs.customTool'))
|
||||
|
||||
expect(screen.getByText('Custom Provider')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Built In Provider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters the rendered tools by the search text', () => {
|
||||
render(
|
||||
<AllTools
|
||||
searchText="report"
|
||||
tags={[]}
|
||||
onSelect={vi.fn()}
|
||||
buildInTools={[
|
||||
createToolProvider({
|
||||
id: 'provider-report',
|
||||
label: { en_US: 'Report Toolkit', zh_Hans: 'Report Toolkit' },
|
||||
}),
|
||||
createToolProvider({
|
||||
id: 'provider-other',
|
||||
label: { en_US: 'Other Toolkit', zh_Hans: 'Other Toolkit' },
|
||||
}),
|
||||
]}
|
||||
customTools={[]}
|
||||
workflowTools={[]}
|
||||
mcpTools={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Report Toolkit')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Other Toolkit')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the empty state when no tool matches the current filter', async () => {
|
||||
render(
|
||||
<AllTools
|
||||
searchText="missing"
|
||||
tags={[]}
|
||||
onSelect={vi.fn()}
|
||||
buildInTools={[]}
|
||||
customTools={[]}
|
||||
workflowTools={[]}
|
||||
mcpTools={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.tabs.noPluginsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { NodeDefault } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum } from '../../types'
|
||||
import Blocks from '../blocks'
|
||||
import { BlockClassificationEnum } from '../types'
|
||||
|
||||
const runtimeState = vi.hoisted(() => ({
|
||||
nodes: [] as Array<{ data: { type?: BlockEnum } }>,
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: () => runtimeState.nodes,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const createBlock = (type: BlockEnum, title: string, classification = BlockClassificationEnum.Default): NodeDefault => ({
|
||||
metaData: {
|
||||
classification,
|
||||
sort: 0,
|
||||
type,
|
||||
title,
|
||||
author: 'Dify',
|
||||
description: `${title} description`,
|
||||
},
|
||||
defaultValue: {},
|
||||
checkValid: () => ({ isValid: true }),
|
||||
})
|
||||
|
||||
describe('Blocks', () => {
|
||||
beforeEach(() => {
|
||||
runtimeState.nodes = []
|
||||
})
|
||||
|
||||
it('renders grouped blocks, filters duplicate knowledge-base nodes, and selects a block', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
runtimeState.nodes = [{ data: { type: BlockEnum.KnowledgeBase } }]
|
||||
|
||||
render(
|
||||
<Blocks
|
||||
searchText=""
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.LoopEnd, BlockEnum.KnowledgeBase]}
|
||||
blocks={[
|
||||
createBlock(BlockEnum.LLM, 'LLM'),
|
||||
createBlock(BlockEnum.LoopEnd, 'Exit Loop', BlockClassificationEnum.Logic),
|
||||
createBlock(BlockEnum.KnowledgeBase, 'Knowledge Retrieval'),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('LLM')).toBeInTheDocument()
|
||||
expect(screen.getByText('Exit Loop')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.loop.loopNode')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Knowledge Retrieval')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('LLM'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.LLM)
|
||||
})
|
||||
|
||||
it('shows the empty state when no block matches the search', () => {
|
||||
render(
|
||||
<Blocks
|
||||
searchText="missing"
|
||||
onSelect={vi.fn()}
|
||||
availableBlocksTypes={[BlockEnum.LLM]}
|
||||
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.tabs.noResult')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
|
||||
export const createTool = (
|
||||
name: string,
|
||||
label: string,
|
||||
description = `${label} description`,
|
||||
): Tool => ({
|
||||
name,
|
||||
author: 'author',
|
||||
label: {
|
||||
en_US: label,
|
||||
zh_Hans: label,
|
||||
},
|
||||
description: {
|
||||
en_US: description,
|
||||
zh_Hans: description,
|
||||
},
|
||||
parameters: [],
|
||||
labels: [],
|
||||
output_schema: {},
|
||||
})
|
||||
|
||||
export const createToolProvider = (
|
||||
overrides: Partial<ToolWithProvider> = {},
|
||||
): ToolWithProvider => ({
|
||||
id: 'provider-1',
|
||||
name: 'provider-one',
|
||||
author: 'Provider Author',
|
||||
description: {
|
||||
en_US: 'Provider description',
|
||||
zh_Hans: 'Provider description',
|
||||
},
|
||||
icon: 'icon',
|
||||
icon_dark: 'icon-dark',
|
||||
label: {
|
||||
en_US: 'Provider One',
|
||||
zh_Hans: 'Provider One',
|
||||
},
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
plugin_id: 'plugin-1',
|
||||
tools: [createTool('tool-a', 'Tool A')],
|
||||
meta: { version: '1.0.0' } as ToolWithProvider['meta'],
|
||||
plugin_unique_identifier: 'plugin-1@1.0.0',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
export const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'org',
|
||||
author: 'author',
|
||||
name: 'Plugin One',
|
||||
plugin_id: 'plugin-1',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'plugin-1@1.0.0',
|
||||
icon: 'icon',
|
||||
verified: true,
|
||||
label: {
|
||||
en_US: 'Plugin One',
|
||||
zh_Hans: 'Plugin One',
|
||||
},
|
||||
brief: {
|
||||
en_US: 'Plugin description',
|
||||
zh_Hans: 'Plugin description',
|
||||
},
|
||||
description: {
|
||||
en_US: 'Plugin description',
|
||||
zh_Hans: 'Plugin description',
|
||||
},
|
||||
introduction: 'Plugin introduction',
|
||||
repository: 'https://example.com/plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
tags: [],
|
||||
badges: [],
|
||||
install_count: 0,
|
||||
endpoint: {
|
||||
settings: [],
|
||||
},
|
||||
verification: {
|
||||
authorized_category: 'community',
|
||||
},
|
||||
from: 'github',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
export const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
enable_marketplace: enableMarketplace,
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import FeaturedTools from '../featured-tools'
|
||||
import { createPlugin, createToolProvider } from './factories'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
|
||||
useMCPToolAvailability: () => ({
|
||||
allowed: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('@/utils/var')>()),
|
||||
getMarketplaceUrl: () => 'https://marketplace.test/tools',
|
||||
}))
|
||||
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
|
||||
describe('FeaturedTools', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockUseGetLanguage.mockReturnValue('en_US')
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
})
|
||||
|
||||
it('shows more featured tools when the list exceeds the initial quota', async () => {
|
||||
const user = userEvent.setup()
|
||||
const plugins = Array.from({ length: 6 }, (_, index) =>
|
||||
createPlugin({
|
||||
plugin_id: `plugin-${index + 1}`,
|
||||
latest_package_identifier: `plugin-${index + 1}@1.0.0`,
|
||||
label: { en_US: `Plugin ${index + 1}`, zh_Hans: `Plugin ${index + 1}` },
|
||||
}))
|
||||
const providers = plugins.map((plugin, index) =>
|
||||
createToolProvider({
|
||||
id: `provider-${index + 1}`,
|
||||
plugin_id: plugin.plugin_id,
|
||||
label: { en_US: `Provider ${index + 1}`, zh_Hans: `Provider ${index + 1}` },
|
||||
}),
|
||||
)
|
||||
const providerMap = new Map(providers.map(provider => [provider.plugin_id!, provider]))
|
||||
|
||||
render(
|
||||
<FeaturedTools
|
||||
plugins={plugins}
|
||||
providerMap={providerMap}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Provider 1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Provider 6')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.tabs.showMoreFeatured'))
|
||||
|
||||
expect(screen.getByText('Provider 6')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('honors the persisted collapsed state', () => {
|
||||
localStorage.setItem('workflow_tools_featured_collapsed', 'true')
|
||||
|
||||
render(
|
||||
<FeaturedTools
|
||||
plugins={[createPlugin()]}
|
||||
providerMap={new Map([[
|
||||
'plugin-1',
|
||||
createToolProvider(),
|
||||
]])}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.tabs.featuredTools')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Provider One')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the marketplace empty state when no featured tools are available', () => {
|
||||
render(
|
||||
<FeaturedTools
|
||||
plugins={[]}
|
||||
providerMap={new Map()}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.tabs.noFeaturedPlugins')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useTabs, useToolTabs } from '../hooks'
|
||||
import { TabsEnum, ToolTypeEnum } from '../types'
|
||||
|
||||
describe('block-selector hooks', () => {
|
||||
it('falls back to the first valid tab when the preferred start tab is disabled', () => {
|
||||
const { result } = renderHook(() => useTabs({
|
||||
noStart: false,
|
||||
hasUserInputNode: true,
|
||||
defaultActiveTab: TabsEnum.Start,
|
||||
}))
|
||||
|
||||
expect(result.current.tabs.find(tab => tab.key === TabsEnum.Start)?.disabled).toBe(true)
|
||||
expect(result.current.activeTab).toBe(TabsEnum.Blocks)
|
||||
})
|
||||
|
||||
it('keeps the start tab enabled when forcing it on and resets to a valid tab after disabling blocks', () => {
|
||||
const props: Parameters<typeof useTabs>[0] = {
|
||||
noBlocks: false,
|
||||
noStart: false,
|
||||
hasUserInputNode: true,
|
||||
forceEnableStartTab: true,
|
||||
}
|
||||
|
||||
const { result, rerender } = renderHook(nextProps => useTabs(nextProps), {
|
||||
initialProps: props,
|
||||
})
|
||||
|
||||
expect(result.current.tabs.find(tab => tab.key === TabsEnum.Start)?.disabled).toBeFalsy()
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveTab(TabsEnum.Blocks)
|
||||
})
|
||||
|
||||
rerender({
|
||||
...props,
|
||||
noBlocks: true,
|
||||
noSources: true,
|
||||
noTools: true,
|
||||
})
|
||||
|
||||
expect(result.current.activeTab).toBe(TabsEnum.Start)
|
||||
})
|
||||
|
||||
it('returns the MCP tab only when it is not hidden', () => {
|
||||
const { result: visible } = renderHook(() => useToolTabs())
|
||||
const { result: hidden } = renderHook(() => useToolTabs(true))
|
||||
|
||||
expect(visible.current.some(tab => tab.key === ToolTypeEnum.MCP)).toBe(true)
|
||||
expect(hidden.current.some(tab => tab.key === ToolTypeEnum.MCP)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { NodeDefault, ToolWithProvider } from '../../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import NodeSelectorWrapper from '../index'
|
||||
import { BlockClassificationEnum } from '../types'
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useFeaturedToolsRecommendations: () => ({
|
||||
plugins: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => ({ data: [] }),
|
||||
useAllCustomTools: () => ({ data: [] }),
|
||||
useAllWorkflowTools: () => ({ data: [] }),
|
||||
useAllMCPTools: () => ({ data: [] }),
|
||||
useInvalidateAllBuiltInTools: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({
|
||||
systemFeatures: { enable_marketplace: false },
|
||||
}),
|
||||
}))
|
||||
|
||||
const createBlock = (type: BlockEnum, title: string): NodeDefault => ({
|
||||
metaData: {
|
||||
type,
|
||||
title,
|
||||
sort: 0,
|
||||
classification: BlockClassificationEnum.Default,
|
||||
author: 'Dify',
|
||||
description: `${title} description`,
|
||||
},
|
||||
defaultValue: {},
|
||||
checkValid: () => ({ isValid: true }),
|
||||
})
|
||||
|
||||
const dataSource: ToolWithProvider = {
|
||||
id: 'datasource-1',
|
||||
name: 'datasource',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Data source', zh_Hans: '数据源' },
|
||||
icon: 'icon',
|
||||
label: { en_US: 'Data Source', zh_Hans: 'Data Source' },
|
||||
type: 'datasource' as ToolWithProvider['type'],
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
tools: [],
|
||||
meta: { version: '1.0.0' } as ToolWithProvider['meta'],
|
||||
}
|
||||
|
||||
describe('NodeSelectorWrapper', () => {
|
||||
it('filters hidden block types from hooks store and forwards data sources', async () => {
|
||||
renderWorkflowComponent(
|
||||
<NodeSelectorWrapper
|
||||
open
|
||||
onSelect={vi.fn()}
|
||||
availableBlocksTypes={[BlockEnum.Code]}
|
||||
/>,
|
||||
{
|
||||
hooksStoreProps: {
|
||||
availableNodesMetaData: {
|
||||
nodes: [
|
||||
createBlock(BlockEnum.Start, 'Start'),
|
||||
createBlock(BlockEnum.Tool, 'Tool'),
|
||||
createBlock(BlockEnum.Code, 'Code'),
|
||||
createBlock(BlockEnum.DataSource, 'Data Source'),
|
||||
],
|
||||
},
|
||||
},
|
||||
initialStoreState: {
|
||||
dataSourceList: [dataSource],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Code')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Start')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Tool')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { NodeDefault } from '../../types'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import NodeSelector from '../main'
|
||||
import { BlockClassificationEnum } from '../types'
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: () => [],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({
|
||||
systemFeatures: { enable_marketplace: false },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useFeaturedToolsRecommendations: () => ({
|
||||
plugins: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => ({ data: [] }),
|
||||
useAllCustomTools: () => ({ data: [] }),
|
||||
useAllWorkflowTools: () => ({ data: [] }),
|
||||
useAllMCPTools: () => ({ data: [] }),
|
||||
useInvalidateAllBuiltInTools: () => vi.fn(),
|
||||
}))
|
||||
|
||||
const createBlock = (type: BlockEnum, title: string): NodeDefault => ({
|
||||
metaData: {
|
||||
classification: BlockClassificationEnum.Default,
|
||||
sort: 0,
|
||||
type,
|
||||
title,
|
||||
author: 'Dify',
|
||||
description: `${title} description`,
|
||||
},
|
||||
defaultValue: {},
|
||||
checkValid: () => ({ isValid: true }),
|
||||
})
|
||||
|
||||
describe('NodeSelector', () => {
|
||||
it('opens with the real blocks tab, filters by search, selects a block, and clears search after close', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
renderWorkflowComponent(
|
||||
<NodeSelector
|
||||
onSelect={onSelect}
|
||||
blocks={[
|
||||
createBlock(BlockEnum.LLM, 'LLM'),
|
||||
createBlock(BlockEnum.End, 'End'),
|
||||
]}
|
||||
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.End]}
|
||||
trigger={open => (
|
||||
<button type="button">
|
||||
{open ? 'selector-open' : 'selector-closed'}
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock')
|
||||
expect(screen.getByText('LLM')).toBeInTheDocument()
|
||||
expect(screen.getByText('End')).toBeInTheDocument()
|
||||
|
||||
await user.type(searchInput, 'LLM')
|
||||
expect(screen.getByText('LLM')).toBeInTheDocument()
|
||||
expect(screen.queryByText('End')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('LLM'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.LLM, undefined)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('workflow.tabs.searchBlock')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
|
||||
|
||||
const reopenedInput = screen.getByPlaceholderText('workflow.tabs.searchBlock') as HTMLInputElement
|
||||
expect(reopenedInput.value).toBe('')
|
||||
expect(screen.getByText('End')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import Tools from '../tools'
|
||||
import { ViewType } from '../view-type-select'
|
||||
import { createToolProvider } from './factories'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
|
||||
useMCPToolAvailability: () => ({
|
||||
allowed: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
|
||||
describe('Tools', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseGetLanguage.mockReturnValue('en_US')
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
})
|
||||
|
||||
it('shows the empty state when there are no tools and no search text', () => {
|
||||
render(
|
||||
<Tools
|
||||
tools={[]}
|
||||
onSelect={vi.fn()}
|
||||
viewType={ViewType.flat}
|
||||
hasSearchText={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('No tools available')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders tree groups for built-in and custom providers', () => {
|
||||
render(
|
||||
<Tools
|
||||
tools={[
|
||||
createToolProvider({
|
||||
id: 'built-in-provider',
|
||||
author: 'Built In',
|
||||
label: { en_US: 'Built In Provider', zh_Hans: 'Built In Provider' },
|
||||
}),
|
||||
createToolProvider({
|
||||
id: 'custom-provider',
|
||||
type: CollectionType.custom,
|
||||
label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' },
|
||||
}),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
viewType={ViewType.tree}
|
||||
hasSearchText={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Built In')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.tabs.customTool')).toBeInTheDocument()
|
||||
expect(screen.getByText('Built In Provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom Provider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the alphabetical index in flat view when enough tools are present', () => {
|
||||
const { container } = render(
|
||||
<Tools
|
||||
tools={Array.from({ length: 11 }, (_, index) =>
|
||||
createToolProvider({
|
||||
id: `provider-${index}`,
|
||||
label: {
|
||||
en_US: `${String.fromCharCode(65 + index)} Provider`,
|
||||
zh_Hans: `${String.fromCharCode(65 + index)} Provider`,
|
||||
},
|
||||
}))}
|
||||
onSelect={vi.fn()}
|
||||
viewType={ViewType.flat}
|
||||
hasSearchText={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.index-bar')).toBeInTheDocument()
|
||||
expect(screen.getByText('A Provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('K Provider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import { createTool, createToolProvider } from '../../__tests__/factories'
|
||||
import { ViewType } from '../../view-type-select'
|
||||
import Tool from '../tool'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
|
||||
useMCPToolAvailability: () => ({
|
||||
allowed: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
const mockTrackEvent = vi.mocked(trackEvent)
|
||||
|
||||
describe('Tool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseGetLanguage.mockReturnValue('en_US')
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
})
|
||||
|
||||
it('expands a provider and selects an action item', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Tool
|
||||
payload={createToolProvider({
|
||||
tools: [
|
||||
createTool('tool-a', 'Tool A'),
|
||||
createTool('tool-b', 'Tool B'),
|
||||
],
|
||||
})}
|
||||
viewType={ViewType.flat}
|
||||
hasSearchText={false}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Provider One'))
|
||||
await user.click(screen.getByText('Tool B'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.Tool, expect.objectContaining({
|
||||
provider_id: 'provider-1',
|
||||
provider_name: 'provider-one',
|
||||
tool_name: 'tool-b',
|
||||
title: 'Tool B',
|
||||
}))
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('tool_selected', {
|
||||
tool_name: 'tool-b',
|
||||
plugin_id: 'plugin-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('selects workflow tools directly without expanding the provider', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<Tool
|
||||
payload={createToolProvider({
|
||||
type: CollectionType.workflow,
|
||||
tools: [createTool('workflow-tool', 'Workflow Tool')],
|
||||
})}
|
||||
viewType={ViewType.flat}
|
||||
hasSearchText={false}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Workflow Tool'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.Tool, expect.objectContaining({
|
||||
provider_type: CollectionType.workflow,
|
||||
tool_name: 'workflow-tool',
|
||||
tool_label: 'Workflow Tool',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { createToolProvider } from '../../../__tests__/factories'
|
||||
import List from '../list'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
|
||||
useMCPToolAvailability: () => ({
|
||||
allowed: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
|
||||
describe('ToolListFlatView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseGetLanguage.mockReturnValue('en_US')
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
})
|
||||
|
||||
it('assigns the first tool of each letter to the shared refs and renders the index bar', () => {
|
||||
const toolRefs = {
|
||||
current: {} as Record<string, HTMLDivElement | null>,
|
||||
}
|
||||
|
||||
render(
|
||||
<List
|
||||
letters={['A', 'B']}
|
||||
payload={[
|
||||
createToolProvider({
|
||||
id: 'provider-a',
|
||||
label: { en_US: 'A Provider', zh_Hans: 'A Provider' },
|
||||
letter: 'A',
|
||||
} as ReturnType<typeof createToolProvider>),
|
||||
createToolProvider({
|
||||
id: 'provider-b',
|
||||
label: { en_US: 'B Provider', zh_Hans: 'B Provider' },
|
||||
letter: 'B',
|
||||
} as ReturnType<typeof createToolProvider>),
|
||||
]}
|
||||
isShowLetterIndex
|
||||
indexBar={<div data-testid="index-bar" />}
|
||||
hasSearchText={false}
|
||||
onSelect={vi.fn()}
|
||||
toolRefs={toolRefs}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('A Provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('B Provider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('index-bar')).toBeInTheDocument()
|
||||
expect(toolRefs.current.A).toBeTruthy()
|
||||
expect(toolRefs.current.B).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { createToolProvider } from '../../../__tests__/factories'
|
||||
import Item from '../item'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
|
||||
useMCPToolAvailability: () => ({
|
||||
allowed: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
|
||||
describe('ToolListTreeView Item', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseGetLanguage.mockReturnValue('en_US')
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
})
|
||||
|
||||
it('renders the group heading and its provider list', () => {
|
||||
render(
|
||||
<Item
|
||||
groupName="My Group"
|
||||
toolList={[createToolProvider({
|
||||
label: { en_US: 'Provider Alpha', zh_Hans: 'Provider Alpha' },
|
||||
})]}
|
||||
hasSearchText={false}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('My Group')).toBeInTheDocument()
|
||||
expect(screen.getByText('Provider Alpha')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { createToolProvider } from '../../../__tests__/factories'
|
||||
import { CUSTOM_GROUP_NAME } from '../../../index-bar'
|
||||
import List from '../list'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
|
||||
useMCPToolAvailability: () => ({
|
||||
allowed: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockUseGetLanguage = vi.mocked(useGetLanguage)
|
||||
const mockUseTheme = vi.mocked(useTheme)
|
||||
|
||||
describe('ToolListTreeView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseGetLanguage.mockReturnValue('en_US')
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
|
||||
})
|
||||
|
||||
it('translates built-in special group names and renders the nested providers', () => {
|
||||
render(
|
||||
<List
|
||||
payload={{
|
||||
BuiltIn: [createToolProvider({
|
||||
label: { en_US: 'Built In Provider', zh_Hans: 'Built In Provider' },
|
||||
})],
|
||||
[CUSTOM_GROUP_NAME]: [createToolProvider({
|
||||
id: 'custom-provider',
|
||||
type: 'custom',
|
||||
label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' },
|
||||
})],
|
||||
}}
|
||||
hasSearchText={false}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('BuiltIn')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.tabs.customTool')).toBeInTheDocument()
|
||||
expect(screen.getByText('Built In Provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom Provider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { DatasetsDetailContext } from '../provider'
|
||||
import { createDatasetsDetailStore, useDatasetsDetailStore } from '../store'
|
||||
|
||||
const createDataset = (id: string, name = `dataset-${id}`): DataSet => ({
|
||||
id,
|
||||
name,
|
||||
indexing_status: 'completed',
|
||||
icon_info: {
|
||||
icon: 'book',
|
||||
icon_type: 'emoji' as DataSet['icon_info']['icon_type'],
|
||||
},
|
||||
description: `${name} description`,
|
||||
permission: DatasetPermission.onlyMe,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
|
||||
created_by: 'user-1',
|
||||
updated_by: 'user-1',
|
||||
updated_at: 1,
|
||||
app_count: 0,
|
||||
doc_form: ChunkingMode.text,
|
||||
document_count: 0,
|
||||
total_document_count: 0,
|
||||
word_count: 0,
|
||||
provider: 'provider',
|
||||
embedding_model: 'model',
|
||||
embedding_model_provider: 'provider',
|
||||
embedding_available: true,
|
||||
retrieval_model_dict: {} as DataSet['retrieval_model_dict'],
|
||||
retrieval_model: {} as DataSet['retrieval_model'],
|
||||
tags: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 1,
|
||||
score_threshold: 0,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
built_in_field_enabled: false,
|
||||
runtime_mode: 'general',
|
||||
enable_api: false,
|
||||
is_multimodal: false,
|
||||
})
|
||||
|
||||
describe('datasets-detail-store store', () => {
|
||||
it('merges dataset details by id', () => {
|
||||
const store = createDatasetsDetailStore()
|
||||
|
||||
store.getState().updateDatasetsDetail([
|
||||
createDataset('dataset-1', 'Dataset One'),
|
||||
createDataset('dataset-2', 'Dataset Two'),
|
||||
])
|
||||
store.getState().updateDatasetsDetail([
|
||||
createDataset('dataset-2', 'Dataset Two Updated'),
|
||||
])
|
||||
|
||||
expect(store.getState().datasetsDetail).toMatchObject({
|
||||
'dataset-1': { name: 'Dataset One' },
|
||||
'dataset-2': { name: 'Dataset Two Updated' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reads state from the datasets detail context', () => {
|
||||
const store = createDatasetsDetailStore()
|
||||
store.getState().updateDatasetsDetail([createDataset('dataset-3')])
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<DatasetsDetailContext.Provider value={store}>
|
||||
{children}
|
||||
</DatasetsDetailContext.Provider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDatasetsDetailStore(state => state.datasetsDetail['dataset-3']?.name),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBe('dataset-dataset-3')
|
||||
})
|
||||
|
||||
it('throws when the datasets detail provider is missing', () => {
|
||||
expect(() => renderHook(() => useDatasetsDetailStore(state => state.datasetsDetail))).toThrow(
|
||||
'Missing DatasetsDetailContext.Provider in the tree',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { HooksStoreContext } from '../provider'
|
||||
import { createHooksStore, useHooksStore } from '../store'
|
||||
|
||||
describe('hooks-store store', () => {
|
||||
it('creates default callbacks and refreshes selected handlers', () => {
|
||||
const store = createHooksStore({})
|
||||
const handleBackupDraft = vi.fn()
|
||||
|
||||
expect(store.getState().availableNodesMetaData).toEqual({ nodes: [] })
|
||||
expect(store.getState().hasNodeInspectVars('node-1')).toBe(false)
|
||||
expect(store.getState().getWorkflowRunAndTraceUrl('run-1')).toEqual({
|
||||
runUrl: '',
|
||||
traceUrl: '',
|
||||
})
|
||||
|
||||
store.getState().refreshAll({ handleBackupDraft })
|
||||
|
||||
expect(store.getState().handleBackupDraft).toBe(handleBackupDraft)
|
||||
})
|
||||
|
||||
it('reads state from the hooks store context', () => {
|
||||
const handleRun = vi.fn()
|
||||
const store = createHooksStore({ handleRun })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<HooksStoreContext.Provider value={store}>
|
||||
{children}
|
||||
</HooksStoreContext.Provider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useHooksStore(state => state.handleRun), { wrapper })
|
||||
|
||||
expect(result.current).toBe(handleRun)
|
||||
})
|
||||
|
||||
it('throws when the hooks store provider is missing', () => {
|
||||
expect(() => renderHook(() => useHooksStore(state => state.handleRun))).toThrow(
|
||||
'Missing HooksStoreContext.Provider in the tree',
|
||||
)
|
||||
})
|
||||
})
|
||||
19
web/app/components/workflow/hooks/__tests__/use-DSL.spec.ts
Normal file
19
web/app/components/workflow/hooks/__tests__/use-DSL.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useDSL } from '../use-DSL'
|
||||
|
||||
describe('useDSL', () => {
|
||||
it('returns the DSL handlers from hooks store', () => {
|
||||
const exportCheck = vi.fn()
|
||||
const handleExportDSL = vi.fn()
|
||||
|
||||
const { result } = renderWorkflowHook(() => useDSL(), {
|
||||
hooksStoreProps: {
|
||||
exportCheck,
|
||||
handleExportDSL,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.current.exportCheck).toBe(exportCheck)
|
||||
expect(result.current.handleExportDSL).toBe(handleExportDSL)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useEdges } from 'reactflow'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus } from '../../types'
|
||||
import { useEdgesInteractionsWithoutSync } from '../use-edges-interactions-without-sync'
|
||||
|
||||
type EdgeRuntimeState = {
|
||||
_sourceRunningStatus?: NodeRunningStatus
|
||||
_targetRunningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
}
|
||||
|
||||
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
|
||||
(edge?.data ?? {}) as EdgeRuntimeState
|
||||
|
||||
const createFlowNodes = () => [
|
||||
createNode({ id: 'a' }),
|
||||
createNode({ id: 'b' }),
|
||||
createNode({ id: 'c' }),
|
||||
]
|
||||
|
||||
const createFlowEdges = () => [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
data: {
|
||||
_sourceRunningStatus: NodeRunningStatus.Running,
|
||||
_targetRunningStatus: NodeRunningStatus.Running,
|
||||
_waitingRun: true,
|
||||
},
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'b',
|
||||
target: 'c',
|
||||
data: {
|
||||
_sourceRunningStatus: NodeRunningStatus.Succeeded,
|
||||
_targetRunningStatus: undefined,
|
||||
_waitingRun: false,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const renderEdgesInteractionsHook = () =>
|
||||
renderWorkflowFlowHook(() => ({
|
||||
...useEdgesInteractionsWithoutSync(),
|
||||
edges: useEdges(),
|
||||
}), {
|
||||
nodes: createFlowNodes(),
|
||||
edges: createFlowEdges(),
|
||||
})
|
||||
|
||||
describe('useEdgesInteractionsWithoutSync', () => {
|
||||
it('clears running status and waitingRun on all edges', () => {
|
||||
const { result } = renderEdgesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeCancelRunningStatus()
|
||||
})
|
||||
|
||||
return waitFor(() => {
|
||||
result.current.edges.forEach((edge) => {
|
||||
const edgeState = getEdgeRuntimeState(edge)
|
||||
expect(edgeState._sourceRunningStatus).toBeUndefined()
|
||||
expect(edgeState._targetRunningStatus).toBeUndefined()
|
||||
expect(edgeState._waitingRun).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mutate the original edges array', () => {
|
||||
const edges = createFlowEdges()
|
||||
const originalData = { ...getEdgeRuntimeState(edges[0]) }
|
||||
const { result } = renderWorkflowFlowHook(() => ({
|
||||
...useEdgesInteractionsWithoutSync(),
|
||||
edges: useEdges(),
|
||||
}), {
|
||||
nodes: createFlowNodes(),
|
||||
edges,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeCancelRunningStatus()
|
||||
})
|
||||
|
||||
expect(getEdgeRuntimeState(edges[0])._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../../utils'
|
||||
import {
|
||||
applyConnectedHandleNodeData,
|
||||
buildContextMenuEdges,
|
||||
clearEdgeMenuIfNeeded,
|
||||
clearNodeSelectionState,
|
||||
updateEdgeHoverState,
|
||||
updateEdgeSelectionState,
|
||||
} from '../use-edges-interactions.helpers'
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetNodesConnectedSourceOrTargetHandleIdsMap = vi.mocked(getNodesConnectedSourceOrTargetHandleIdsMap)
|
||||
|
||||
describe('use-edges-interactions.helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('applyConnectedHandleNodeData should merge connected handle metadata into matching nodes', () => {
|
||||
mockGetNodesConnectedSourceOrTargetHandleIdsMap.mockReturnValue({
|
||||
'node-1': {
|
||||
_connectedSourceHandleIds: ['branch-a'],
|
||||
},
|
||||
})
|
||||
|
||||
const nodes = [
|
||||
createNode({ id: 'node-1', data: { title: 'Source' } }),
|
||||
createNode({ id: 'node-2', data: { title: 'Target' } }),
|
||||
]
|
||||
const edgeChanges = [{
|
||||
type: 'add',
|
||||
edge: createEdge({ id: 'edge-1', source: 'node-1', target: 'node-2' }),
|
||||
}]
|
||||
|
||||
const result = applyConnectedHandleNodeData(nodes, edgeChanges)
|
||||
|
||||
expect(result[0].data._connectedSourceHandleIds).toEqual(['branch-a'])
|
||||
expect(result[1].data._connectedSourceHandleIds).toEqual([])
|
||||
expect(mockGetNodesConnectedSourceOrTargetHandleIdsMap).toHaveBeenCalledWith(edgeChanges, nodes)
|
||||
})
|
||||
|
||||
it('clearEdgeMenuIfNeeded should return true only when the open menu belongs to a removed edge', () => {
|
||||
expect(clearEdgeMenuIfNeeded({
|
||||
edgeMenu: { edgeId: 'edge-1' },
|
||||
edgeIds: ['edge-1', 'edge-2'],
|
||||
})).toBe(true)
|
||||
|
||||
expect(clearEdgeMenuIfNeeded({
|
||||
edgeMenu: { edgeId: 'edge-3' },
|
||||
edgeIds: ['edge-1', 'edge-2'],
|
||||
})).toBe(false)
|
||||
|
||||
expect(clearEdgeMenuIfNeeded({
|
||||
edgeIds: ['edge-1'],
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('updateEdgeHoverState should toggle only the hovered edge flag', () => {
|
||||
const edges = [
|
||||
createEdge({ id: 'edge-1', data: { _hovering: false } }),
|
||||
createEdge({ id: 'edge-2', data: { _hovering: false } }),
|
||||
]
|
||||
|
||||
const result = updateEdgeHoverState(edges, 'edge-2', true)
|
||||
|
||||
expect(result.find(edge => edge.id === 'edge-1')?.data._hovering).toBe(false)
|
||||
expect(result.find(edge => edge.id === 'edge-2')?.data._hovering).toBe(true)
|
||||
})
|
||||
|
||||
it('updateEdgeSelectionState should update selected flags for select changes only', () => {
|
||||
const edges = [
|
||||
createEdge({ id: 'edge-1', selected: false }),
|
||||
createEdge({ id: 'edge-2', selected: true }),
|
||||
]
|
||||
|
||||
const result = updateEdgeSelectionState(edges, [
|
||||
{ type: 'select', id: 'edge-1', selected: true },
|
||||
{ type: 'remove', id: 'edge-2' },
|
||||
])
|
||||
|
||||
expect(result.find(edge => edge.id === 'edge-1')?.selected).toBe(true)
|
||||
expect(result.find(edge => edge.id === 'edge-2')?.selected).toBe(true)
|
||||
})
|
||||
|
||||
it('buildContextMenuEdges should select the target edge and clear bundled markers', () => {
|
||||
const edges = [
|
||||
createEdge({ id: 'edge-1', selected: true, data: { _isBundled: true } }),
|
||||
createEdge({ id: 'edge-2', selected: false, data: { _isBundled: true } }),
|
||||
]
|
||||
|
||||
const result = buildContextMenuEdges(edges, 'edge-2')
|
||||
|
||||
expect(result.find(edge => edge.id === 'edge-1')?.selected).toBe(false)
|
||||
expect(result.find(edge => edge.id === 'edge-2')?.selected).toBe(true)
|
||||
expect(result.every(edge => edge.data._isBundled === false)).toBe(true)
|
||||
})
|
||||
|
||||
it('clearNodeSelectionState should clear selected state and bundled markers on every node', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'node-1', selected: true, data: { selected: true, _isBundled: true } }),
|
||||
createNode({ id: 'node-2', selected: false, data: { selected: true, _isBundled: true } }),
|
||||
]
|
||||
|
||||
const result = clearNodeSelectionState(nodes)
|
||||
|
||||
expect(result.every(node => node.selected === false)).toBe(true)
|
||||
expect(result.every(node => node.data.selected === false)).toBe(true)
|
||||
expect(result.every(node => node.data._isBundled === false)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { SchemaTypeDefinition } from '@/service/use-common'
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, VarType } from '../../types'
|
||||
import { useSetWorkflowVarsWithValue } from '../use-fetch-workflow-inspect-vars'
|
||||
|
||||
const mockFetchAllInspectVars = vi.hoisted(() => vi.fn())
|
||||
const mockInvalidateConversationVarValues = vi.hoisted(() => vi.fn())
|
||||
const mockInvalidateSysVarValues = vi.hoisted(() => vi.fn())
|
||||
const mockHandleCancelAllNodeSuccessStatus = vi.hoisted(() => vi.fn())
|
||||
const mockToNodeOutputVars = vi.hoisted(() => vi.fn())
|
||||
|
||||
const schemaTypeDefinitions: SchemaTypeDefinition[] = [{
|
||||
name: 'simple',
|
||||
schema: {
|
||||
properties: {},
|
||||
},
|
||||
}]
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
vi.mock('@/service/use-tools', async () =>
|
||||
(await import('../../__tests__/service-mock-factory')).createToolServiceMock())
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidateConversationVarValues: () => mockInvalidateConversationVarValues,
|
||||
useInvalidateSysVarValues: () => mockInvalidateSysVarValues,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchAllInspectVars: (...args: unknown[]) => mockFetchAllInspectVars(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-interactions-without-sync', () => ({
|
||||
useNodesInteractionsWithoutSync: () => ({
|
||||
handleCancelAllNodeSuccessStatus: mockHandleCancelAllNodeSuccessStatus,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/use-match-schema-type', () => ({
|
||||
default: () => ({
|
||||
schemaTypeDefinitions,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
|
||||
toNodeOutputVars: (...args: unknown[]) => mockToNodeOutputVars(...args),
|
||||
}))
|
||||
|
||||
const createInspectVar = (overrides: Partial<VarInInspect> = {}): VarInInspect => ({
|
||||
id: 'var-1',
|
||||
type: 'node',
|
||||
name: 'answer',
|
||||
description: 'Answer',
|
||||
selector: ['node-1', 'answer'],
|
||||
value_type: VarType.string,
|
||||
value: 'hello',
|
||||
edited: false,
|
||||
visible: true,
|
||||
is_truncated: false,
|
||||
full_content: {
|
||||
size_bytes: 5,
|
||||
download_url: 'https://example.com/answer.txt',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('use-fetch-workflow-inspect-vars', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
createNode({
|
||||
id: 'node-1',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
},
|
||||
}),
|
||||
]
|
||||
mockToNodeOutputVars.mockReturnValue([{
|
||||
nodeId: 'node-1',
|
||||
vars: [{
|
||||
variable: 'answer',
|
||||
schemaType: 'simple',
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('fetches inspect vars, invalidates cached values, and stores schema-enriched node vars', async () => {
|
||||
mockFetchAllInspectVars.mockResolvedValue([
|
||||
createInspectVar(),
|
||||
createInspectVar({
|
||||
id: 'missing-node-var',
|
||||
selector: ['missing-node', 'answer'],
|
||||
}),
|
||||
])
|
||||
|
||||
const { result, store } = renderWorkflowHook(
|
||||
() => useSetWorkflowVarsWithValue({
|
||||
flowType: FlowType.appFlow,
|
||||
flowId: 'flow-1',
|
||||
}),
|
||||
{
|
||||
initialStoreState: {
|
||||
dataSourceList: [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchInspectVars({})
|
||||
})
|
||||
|
||||
expect(mockInvalidateConversationVarValues).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateSysVarValues).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetchAllInspectVars).toHaveBeenCalledWith(FlowType.appFlow, 'flow-1')
|
||||
expect(mockHandleCancelAllNodeSuccessStatus).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().nodesWithInspectVars).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeId: 'node-1',
|
||||
nodeType: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
vars: [
|
||||
expect.objectContaining({
|
||||
id: 'var-1',
|
||||
selector: ['node-1', 'answer'],
|
||||
schemaType: 'simple',
|
||||
value: 'hello',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts passed-in vars and plugin metadata without refetching from the API', async () => {
|
||||
const passedInVars = [
|
||||
createInspectVar({
|
||||
id: 'var-2',
|
||||
value: 'passed-in',
|
||||
}),
|
||||
]
|
||||
const passedInPluginInfo = {
|
||||
buildInTools: [],
|
||||
customTools: [],
|
||||
workflowTools: [],
|
||||
mcpTools: [],
|
||||
dataSourceList: [],
|
||||
}
|
||||
|
||||
const { result, store } = renderWorkflowHook(
|
||||
() => useSetWorkflowVarsWithValue({
|
||||
flowType: FlowType.appFlow,
|
||||
flowId: 'flow-2',
|
||||
}),
|
||||
{
|
||||
initialStoreState: {
|
||||
dataSourceList: [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchInspectVars({
|
||||
passInVars: true,
|
||||
vars: passedInVars,
|
||||
passedInAllPluginInfoList: passedInPluginInfo,
|
||||
passedInSchemaTypeDefinitions: schemaTypeDefinitions,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAllInspectVars).not.toHaveBeenCalled()
|
||||
expect(store.getState().nodesWithInspectVars[0]?.vars[0]).toMatchObject({
|
||||
id: 'var-2',
|
||||
value: 'passed-in',
|
||||
schemaType: 'simple',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { SchemaTypeDefinition } from '@/service/use-common'
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, VarType } from '../../types'
|
||||
import { useInspectVarsCrudCommon } from '../use-inspect-vars-crud-common'
|
||||
|
||||
const mockFetchNodeInspectVars = vi.hoisted(() => vi.fn())
|
||||
const mockDoDeleteAllInspectorVars = vi.hoisted(() => vi.fn())
|
||||
const mockInvalidateConversationVarValues = vi.hoisted(() => vi.fn())
|
||||
const mockInvalidateSysVarValues = vi.hoisted(() => vi.fn())
|
||||
const mockHandleCancelNodeSuccessStatus = vi.hoisted(() => vi.fn())
|
||||
const mockHandleEdgeCancelRunningStatus = vi.hoisted(() => vi.fn())
|
||||
const mockToNodeOutputVars = vi.hoisted(() => vi.fn())
|
||||
|
||||
const schemaTypeDefinitions: SchemaTypeDefinition[] = [{
|
||||
name: 'simple',
|
||||
schema: {
|
||||
properties: {},
|
||||
},
|
||||
}]
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
vi.mock('@/service/use-flow', () => ({
|
||||
default: () => ({
|
||||
useInvalidateConversationVarValues: () => mockInvalidateConversationVarValues,
|
||||
useInvalidateSysVarValues: () => mockInvalidateSysVarValues,
|
||||
useResetConversationVar: () => ({ mutateAsync: vi.fn() }),
|
||||
useResetToLastRunValue: () => ({ mutateAsync: vi.fn() }),
|
||||
useDeleteAllInspectorVars: () => ({ mutateAsync: mockDoDeleteAllInspectorVars }),
|
||||
useDeleteNodeInspectorVars: () => ({ mutate: vi.fn() }),
|
||||
useDeleteInspectVar: () => ({ mutate: vi.fn() }),
|
||||
useEditInspectorVar: () => ({ mutateAsync: vi.fn() }),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', async () =>
|
||||
(await import('../../__tests__/service-mock-factory')).createToolServiceMock())
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchNodeInspectVars: (...args: unknown[]) => mockFetchNodeInspectVars(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-interactions-without-sync', () => ({
|
||||
useNodesInteractionsWithoutSync: () => ({
|
||||
handleCancelNodeSuccessStatus: mockHandleCancelNodeSuccessStatus,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-edges-interactions-without-sync', () => ({
|
||||
useEdgesInteractionsWithoutSync: () => ({
|
||||
handleEdgeCancelRunningStatus: mockHandleEdgeCancelRunningStatus,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('@/app/components/workflow/nodes/_base/components/variable/utils')>()),
|
||||
toNodeOutputVars: (...args: unknown[]) => mockToNodeOutputVars(...args),
|
||||
}))
|
||||
|
||||
const createInspectVar = (overrides: Partial<VarInInspect> = {}): VarInInspect => ({
|
||||
id: 'var-1',
|
||||
type: 'node',
|
||||
name: 'answer',
|
||||
description: 'Answer',
|
||||
selector: ['node-1', 'answer'],
|
||||
value_type: VarType.string,
|
||||
value: 'hello',
|
||||
edited: false,
|
||||
visible: true,
|
||||
is_truncated: false,
|
||||
full_content: {
|
||||
size_bytes: 5,
|
||||
download_url: 'https://example.com/answer.txt',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useInspectVarsCrudCommon', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
createNode({
|
||||
id: 'node-1',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
},
|
||||
}),
|
||||
]
|
||||
mockToNodeOutputVars.mockReturnValue([{
|
||||
nodeId: 'node-1',
|
||||
vars: [{
|
||||
variable: 'answer',
|
||||
schemaType: 'simple',
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('invalidates cached system vars without refetching node values for system selectors', async () => {
|
||||
const { result } = renderWorkflowHook(
|
||||
() => useInspectVarsCrudCommon({
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
}),
|
||||
{
|
||||
initialStoreState: {
|
||||
dataSourceList: [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchInspectVarValue(['sys', 'query'], schemaTypeDefinitions)
|
||||
})
|
||||
|
||||
expect(mockInvalidateSysVarValues).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetchNodeInspectVars).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches node inspect vars, adds schema types, and marks the node as fetched', async () => {
|
||||
mockFetchNodeInspectVars.mockResolvedValue([
|
||||
createInspectVar(),
|
||||
])
|
||||
|
||||
const { result, store } = renderWorkflowHook(
|
||||
() => useInspectVarsCrudCommon({
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
}),
|
||||
{
|
||||
initialStoreState: {
|
||||
dataSourceList: [],
|
||||
nodesWithInspectVars: [{
|
||||
nodeId: 'node-1',
|
||||
nodePayload: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
} as never,
|
||||
nodeType: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
vars: [],
|
||||
}],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchInspectVarValue(['node-1', 'answer'], schemaTypeDefinitions)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchNodeInspectVars).toHaveBeenCalledWith(FlowType.appFlow, 'flow-1', 'node-1')
|
||||
expect(store.getState().nodesWithInspectVars[0]).toMatchObject({
|
||||
nodeId: 'node-1',
|
||||
isValueFetched: true,
|
||||
vars: [
|
||||
expect.objectContaining({
|
||||
id: 'var-1',
|
||||
schemaType: 'simple',
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes all inspect vars, invalidates cached values, and clears edge running state', async () => {
|
||||
mockDoDeleteAllInspectorVars.mockResolvedValue(undefined)
|
||||
|
||||
const { result, store } = renderWorkflowHook(
|
||||
() => useInspectVarsCrudCommon({
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
}),
|
||||
{
|
||||
initialStoreState: {
|
||||
nodesWithInspectVars: [{
|
||||
nodeId: 'node-1',
|
||||
nodePayload: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
} as never,
|
||||
nodeType: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
vars: [createInspectVar()],
|
||||
}],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteAllInspectorVars()
|
||||
})
|
||||
|
||||
expect(mockDoDeleteAllInspectorVars).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateConversationVarValues).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateSysVarValues).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleEdgeCancelRunningStatus).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().nodesWithInspectVars).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, VarType } from '../../types'
|
||||
import useInspectVarsCrud from '../use-inspect-vars-crud'
|
||||
|
||||
const mockUseConversationVarValues = vi.hoisted(() => vi.fn())
|
||||
const mockUseSysVarValues = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useConversationVarValues: (...args: unknown[]) => mockUseConversationVarValues(...args),
|
||||
useSysVarValues: (...args: unknown[]) => mockUseSysVarValues(...args),
|
||||
}))
|
||||
|
||||
const createInspectVar = (overrides: Partial<VarInInspect> = {}): VarInInspect => ({
|
||||
id: 'var-1',
|
||||
type: 'node',
|
||||
name: 'answer',
|
||||
description: 'Answer',
|
||||
selector: ['node-1', 'answer'],
|
||||
value_type: VarType.string,
|
||||
value: 'hello',
|
||||
edited: false,
|
||||
visible: true,
|
||||
is_truncated: false,
|
||||
full_content: {
|
||||
size_bytes: 5,
|
||||
download_url: 'https://example.com/answer.txt',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useInspectVarsCrud', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseConversationVarValues.mockReturnValue({
|
||||
data: [createInspectVar({
|
||||
id: 'conversation-var',
|
||||
name: 'history',
|
||||
selector: ['conversation', 'history'],
|
||||
})],
|
||||
})
|
||||
mockUseSysVarValues.mockReturnValue({
|
||||
data: [
|
||||
createInspectVar({
|
||||
id: 'query-var',
|
||||
name: 'query',
|
||||
selector: ['sys', 'query'],
|
||||
}),
|
||||
createInspectVar({
|
||||
id: 'files-var',
|
||||
name: 'files',
|
||||
selector: ['sys', 'files'],
|
||||
}),
|
||||
createInspectVar({
|
||||
id: 'time-var',
|
||||
name: 'time',
|
||||
selector: ['sys', 'time'],
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('appends query/files system vars to start-node inspect vars and filters them from the system list', () => {
|
||||
const hasNodeInspectVars = vi.fn(() => true)
|
||||
const deleteAllInspectorVars = vi.fn()
|
||||
const fetchInspectVarValue = vi.fn()
|
||||
|
||||
const { result } = renderWorkflowHook(() => useInspectVarsCrud(), {
|
||||
initialStoreState: {
|
||||
nodesWithInspectVars: [{
|
||||
nodeId: 'start-node',
|
||||
nodePayload: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
} as never,
|
||||
nodeType: BlockEnum.Start,
|
||||
title: 'Start',
|
||||
vars: [createInspectVar({
|
||||
id: 'start-answer',
|
||||
selector: ['start-node', 'answer'],
|
||||
})],
|
||||
}],
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {} as never,
|
||||
},
|
||||
hasNodeInspectVars,
|
||||
fetchInspectVarValue,
|
||||
editInspectVarValue: vi.fn(),
|
||||
renameInspectVarName: vi.fn(),
|
||||
appendNodeInspectVars: vi.fn(),
|
||||
deleteInspectVar: vi.fn(),
|
||||
deleteNodeInspectorVars: vi.fn(),
|
||||
deleteAllInspectorVars,
|
||||
isInspectVarEdited: vi.fn(() => false),
|
||||
resetToLastRunVar: vi.fn(),
|
||||
invalidateSysVarValues: vi.fn(),
|
||||
resetConversationVar: vi.fn(),
|
||||
invalidateConversationVarValues: vi.fn(),
|
||||
hasSetInspectVar: vi.fn(() => false),
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.current.conversationVars).toHaveLength(1)
|
||||
expect(result.current.systemVars.map(item => item.name)).toEqual(['time'])
|
||||
expect(result.current.nodesWithInspectVars[0]?.vars.map(item => item.name)).toEqual([
|
||||
'answer',
|
||||
'query',
|
||||
'files',
|
||||
])
|
||||
expect(result.current.hasNodeInspectVars).toBe(hasNodeInspectVars)
|
||||
expect(result.current.fetchInspectVarValue).toBe(fetchInspectVarValue)
|
||||
expect(result.current.deleteAllInspectorVars).toBe(deleteAllInspectorVars)
|
||||
})
|
||||
|
||||
it('uses an empty flow id for rag pipeline conversation and system value queries', () => {
|
||||
renderWorkflowHook(() => useInspectVarsCrud(), {
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'rag-flow',
|
||||
flowType: FlowType.ragPipeline,
|
||||
fileSettings: {} as never,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockUseConversationVarValues).toHaveBeenCalledWith(FlowType.ragPipeline, '')
|
||||
expect(mockUseSysVarValues).toHaveBeenCalledWith(FlowType.ragPipeline, '')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Node, NodeOutPutVar, Var } from '../../types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { BlockEnum, VarType } from '../../types'
|
||||
import useNodesAvailableVarList, { useGetNodesAvailableVarList } from '../use-nodes-available-var-list'
|
||||
|
||||
const mockGetTreeLeafNodes = vi.hoisted(() => vi.fn())
|
||||
const mockGetBeforeNodesInSameBranchIncludeParent = vi.hoisted(() => vi.fn())
|
||||
const mockGetNodeAvailableVars = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useIsChatMode: () => true,
|
||||
useWorkflow: () => ({
|
||||
getTreeLeafNodes: mockGetTreeLeafNodes,
|
||||
getBeforeNodesInSameBranchIncludeParent: mockGetBeforeNodesInSameBranchIncludeParent,
|
||||
}),
|
||||
useWorkflowVariables: () => ({
|
||||
getNodeAvailableVars: mockGetNodeAvailableVars,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createNode = (overrides: Partial<Node> = {}): Node => ({
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Node',
|
||||
desc: '',
|
||||
},
|
||||
...overrides,
|
||||
} as Node)
|
||||
|
||||
const outputVars: NodeOutPutVar[] = [{
|
||||
nodeId: 'vars-node',
|
||||
title: 'Vars',
|
||||
vars: [{
|
||||
variable: 'name',
|
||||
type: VarType.string,
|
||||
}] satisfies Var[],
|
||||
}]
|
||||
|
||||
describe('useNodesAvailableVarList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetBeforeNodesInSameBranchIncludeParent.mockImplementation((nodeId: string) => [createNode({ id: `before-${nodeId}` })])
|
||||
mockGetTreeLeafNodes.mockImplementation((nodeId: string) => [createNode({ id: `leaf-${nodeId}` })])
|
||||
mockGetNodeAvailableVars.mockReturnValue(outputVars)
|
||||
})
|
||||
|
||||
it('builds availability per node, carrying loop nodes and parent iteration context', () => {
|
||||
const loopNode = createNode({
|
||||
id: 'loop-1',
|
||||
data: {
|
||||
type: BlockEnum.Loop,
|
||||
title: 'Loop',
|
||||
desc: '',
|
||||
},
|
||||
})
|
||||
const childNode = createNode({
|
||||
id: 'child-1',
|
||||
parentId: 'loop-1',
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Writer',
|
||||
desc: '',
|
||||
},
|
||||
})
|
||||
const filterVar = vi.fn(() => true)
|
||||
|
||||
const { result } = renderHook(() => useNodesAvailableVarList([loopNode, childNode], {
|
||||
filterVar,
|
||||
hideEnv: true,
|
||||
hideChatVar: true,
|
||||
}))
|
||||
|
||||
expect(mockGetBeforeNodesInSameBranchIncludeParent).toHaveBeenCalledWith('loop-1')
|
||||
expect(mockGetBeforeNodesInSameBranchIncludeParent).toHaveBeenCalledWith('child-1')
|
||||
expect(result.current['loop-1']?.availableNodes.map(node => node.id)).toEqual(['before-loop-1', 'loop-1'])
|
||||
expect(result.current['child-1']?.availableVars).toBe(outputVars)
|
||||
expect(mockGetNodeAvailableVars).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
parentNode: loopNode,
|
||||
isChatMode: true,
|
||||
filterVar,
|
||||
hideEnv: true,
|
||||
hideChatVar: true,
|
||||
}))
|
||||
})
|
||||
|
||||
it('returns a callback version that can use leaf nodes or caller-provided nodes', () => {
|
||||
const firstNode = createNode({ id: 'node-a' })
|
||||
const secondNode = createNode({ id: 'node-b' })
|
||||
const filterVar = vi.fn(() => true)
|
||||
const passedInAvailableNodes = [createNode({ id: 'manual-node' })]
|
||||
|
||||
const { result } = renderHook(() => useGetNodesAvailableVarList())
|
||||
|
||||
const leafMap = result.current.getNodesAvailableVarList([firstNode], {
|
||||
onlyLeafNodeVar: true,
|
||||
filterVar,
|
||||
})
|
||||
const manualMap = result.current.getNodesAvailableVarList([secondNode], {
|
||||
filterVar,
|
||||
passedInAvailableNodes,
|
||||
})
|
||||
|
||||
expect(mockGetTreeLeafNodes).toHaveBeenCalledWith('node-a')
|
||||
expect(leafMap['node-a']?.availableNodes.map(node => node.id)).toEqual(['leaf-node-a'])
|
||||
expect(manualMap['node-b']?.availableNodes).toBe(passedInAvailableNodes)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus } from '../../types'
|
||||
import { useNodesInteractionsWithoutSync } from '../use-nodes-interactions-without-sync'
|
||||
|
||||
type NodeRuntimeState = {
|
||||
_runningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
}
|
||||
|
||||
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
|
||||
(node?.data ?? {}) as NodeRuntimeState
|
||||
|
||||
const createFlowNodes = () => [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } }),
|
||||
createNode({ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } }),
|
||||
createNode({ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } }),
|
||||
]
|
||||
|
||||
const renderNodesInteractionsHook = () =>
|
||||
renderWorkflowFlowHook(() => ({
|
||||
...useNodesInteractionsWithoutSync(),
|
||||
nodes: useNodes(),
|
||||
}), {
|
||||
nodes: createFlowNodes(),
|
||||
edges: [],
|
||||
})
|
||||
|
||||
describe('useNodesInteractionsWithoutSync', () => {
|
||||
it('clears _runningStatus and _waitingRun on all nodes', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleNodeCancelRunningStatus()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
result.current.nodes.forEach((node) => {
|
||||
const nodeState = getNodeRuntimeState(node)
|
||||
expect(nodeState._runningStatus).toBeUndefined()
|
||||
expect(nodeState._waitingRun).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('clears _runningStatus only for Succeeded nodes', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancelAllNodeSuccessStatus()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const n1 = result.current.nodes.find(node => node.id === 'n1')
|
||||
const n2 = result.current.nodes.find(node => node.id === 'n2')
|
||||
const n3 = result.current.nodes.find(node => node.id === 'n3')
|
||||
|
||||
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
|
||||
expect(getNodeRuntimeState(n3)._runningStatus).toBe(NodeRunningStatus.Failed)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not modify _waitingRun when clearing all success status', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancelAllNodeSuccessStatus()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._waitingRun).toBe(true)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n3'))._waitingRun).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('clears _runningStatus and _waitingRun for the specified succeeded node', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancelNodeSuccessStatus('n2')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const n2 = result.current.nodes.find(node => node.id === 'n2')
|
||||
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
|
||||
expect(getNodeRuntimeState(n2)._waitingRun).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not modify nodes that are not succeeded', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancelNodeSuccessStatus('n1')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const n1 = result.current.nodes.find(node => node.id === 'n1')
|
||||
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(n1)._waitingRun).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not modify other nodes', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancelNodeSuccessStatus('n2')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const n1 = result.current.nodes.find(node => node.id === 'n1')
|
||||
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { Edge, Node } from '../../types'
|
||||
import { act } from '@testing-library/react'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { useNodesInteractions } from '../use-nodes-interactions'
|
||||
|
||||
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
|
||||
const mockSaveStateToHistory = vi.hoisted(() => vi.fn())
|
||||
const mockUndo = vi.hoisted(() => vi.fn())
|
||||
const mockRedo = vi.hoisted(() => vi.fn())
|
||||
|
||||
const runtimeState = vi.hoisted(() => ({
|
||||
nodesReadOnly: false,
|
||||
workflowReadOnly: false,
|
||||
}))
|
||||
|
||||
let currentNodes: Node[] = []
|
||||
let currentEdges: Edge[] = []
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
vi.mock('../use-workflow', () => ({
|
||||
useWorkflow: () => ({
|
||||
getAfterNodesInSameBranch: () => [],
|
||||
}),
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: () => runtimeState.nodesReadOnly,
|
||||
}),
|
||||
useWorkflowReadOnly: () => ({
|
||||
getWorkflowReadOnly: () => runtimeState.workflowReadOnly,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-helpline', () => ({
|
||||
useHelpline: () => ({
|
||||
handleSetHelpline: () => ({
|
||||
showHorizontalHelpLineNodes: [],
|
||||
showVerticalHelpLineNodes: [],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-meta-data', () => ({
|
||||
useNodesMetaData: () => ({
|
||||
nodesMap: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-auto-generate-webhook-url', () => ({
|
||||
useAutoGenerateWebhookUrl: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../use-inspect-vars-crud', () => ({
|
||||
default: () => ({
|
||||
deleteNodeInspectorVars: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../nodes/iteration/use-interactions', () => ({
|
||||
useNodeIterationInteractions: () => ({
|
||||
handleNodeIterationChildDrag: () => ({ restrictPosition: {} }),
|
||||
handleNodeIterationChildrenCopy: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../nodes/loop/use-interactions', () => ({
|
||||
useNodeLoopInteractions: () => ({
|
||||
handleNodeLoopChildDrag: () => ({ restrictPosition: {} }),
|
||||
handleNodeLoopChildrenCopy: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow-history', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../use-workflow-history')>()),
|
||||
useWorkflowHistory: () => ({
|
||||
saveStateToHistory: mockSaveStateToHistory,
|
||||
undo: mockUndo,
|
||||
redo: mockRedo,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useNodesInteractions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
runtimeState.nodesReadOnly = false
|
||||
runtimeState.workflowReadOnly = false
|
||||
currentNodes = [
|
||||
createNode({
|
||||
id: 'node-1',
|
||||
position: { x: 10, y: 20 },
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
},
|
||||
}),
|
||||
]
|
||||
currentEdges = [
|
||||
createEdge({
|
||||
id: 'edge-1',
|
||||
source: 'node-1',
|
||||
target: 'node-2',
|
||||
}),
|
||||
]
|
||||
rfState.nodes = currentNodes as unknown as typeof rfState.nodes
|
||||
rfState.edges = currentEdges as unknown as typeof rfState.edges
|
||||
})
|
||||
|
||||
it('persists node drags only when the node position actually changes', () => {
|
||||
const node = currentNodes[0]
|
||||
const movedNode = {
|
||||
...node,
|
||||
position: { x: 120, y: 80 },
|
||||
}
|
||||
|
||||
const { result, store } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||
historyStore: {
|
||||
nodes: currentNodes,
|
||||
edges: currentEdges,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleNodeDragStart({} as never, node, currentNodes)
|
||||
result.current.handleNodeDragStop({} as never, movedNode, currentNodes)
|
||||
})
|
||||
|
||||
expect(store.getState().nodeAnimation).toBe(false)
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodeDragStop', {
|
||||
nodeId: 'node-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('restores history snapshots on undo and clears the edge menu', () => {
|
||||
const historyNodes = [
|
||||
createNode({
|
||||
id: 'history-node',
|
||||
data: {
|
||||
type: BlockEnum.End,
|
||||
title: 'End',
|
||||
desc: '',
|
||||
},
|
||||
}),
|
||||
]
|
||||
const historyEdges = [
|
||||
createEdge({
|
||||
id: 'history-edge',
|
||||
source: 'history-node',
|
||||
target: 'node-1',
|
||||
}),
|
||||
]
|
||||
|
||||
const { result, store } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
id: 'edge-1',
|
||||
} as never,
|
||||
},
|
||||
historyStore: {
|
||||
nodes: historyNodes,
|
||||
edges: historyEdges,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleHistoryBack()
|
||||
})
|
||||
|
||||
expect(mockUndo).toHaveBeenCalledTimes(1)
|
||||
expect(rfState.setNodes).toHaveBeenCalledWith(historyNodes)
|
||||
expect(rfState.setEdges).toHaveBeenCalledWith(historyEdges)
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips undo and redo when the workflow is read-only', () => {
|
||||
runtimeState.workflowReadOnly = true
|
||||
const { result } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||
historyStore: {
|
||||
nodes: currentNodes,
|
||||
edges: currentEdges,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleHistoryBack()
|
||||
result.current.handleHistoryForward()
|
||||
})
|
||||
|
||||
expect(mockUndo).not.toHaveBeenCalled()
|
||||
expect(mockRedo).not.toHaveBeenCalled()
|
||||
expect(rfState.setNodes).not.toHaveBeenCalled()
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { Node } from '../../types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { useNodeMetaData, useNodesMetaData } from '../use-nodes-meta-data'
|
||||
|
||||
const buildInToolsState = vi.hoisted(() => [] as Array<{ id: string, author: string, description: Record<string, string> }>)
|
||||
const customToolsState = vi.hoisted(() => [] as Array<{ id: string, author: string, description: Record<string, string> }>)
|
||||
const workflowToolsState = vi.hoisted(() => [] as Array<{ id: string, author: string, description: Record<string, string> }>)
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => ({ data: buildInToolsState }),
|
||||
useAllCustomTools: () => ({ data: customToolsState }),
|
||||
useAllWorkflowTools: () => ({ data: workflowToolsState }),
|
||||
}))
|
||||
|
||||
const createNode = (overrides: Partial<Node> = {}): Node => ({
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Node',
|
||||
desc: '',
|
||||
},
|
||||
...overrides,
|
||||
} as Node)
|
||||
|
||||
describe('useNodesMetaData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
buildInToolsState.length = 0
|
||||
customToolsState.length = 0
|
||||
workflowToolsState.length = 0
|
||||
})
|
||||
|
||||
it('returns empty metadata collections when the hooks store has no node map', () => {
|
||||
const { result } = renderWorkflowHook(() => useNodesMetaData(), {
|
||||
hooksStoreProps: {},
|
||||
})
|
||||
|
||||
expect(result.current).toEqual({
|
||||
nodes: [],
|
||||
nodesMap: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves built-in tool metadata from tool providers', () => {
|
||||
buildInToolsState.push({
|
||||
id: 'provider-1',
|
||||
author: 'Provider Author',
|
||||
description: {
|
||||
'en-US': 'Built-in provider description',
|
||||
},
|
||||
})
|
||||
|
||||
const toolNode = createNode({
|
||||
data: {
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Tool Node',
|
||||
desc: '',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_id: 'provider-1',
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderWorkflowHook(() => useNodeMetaData(toolNode), {
|
||||
hooksStoreProps: {
|
||||
availableNodesMetaData: {
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.current).toEqual(expect.objectContaining({
|
||||
author: 'Provider Author',
|
||||
description: 'Built-in provider description',
|
||||
}))
|
||||
})
|
||||
|
||||
it('prefers workflow store data for datasource nodes and keeps generic metadata for normal blocks', () => {
|
||||
const datasourceNode = createNode({
|
||||
data: {
|
||||
type: BlockEnum.DataSource,
|
||||
title: 'Dataset',
|
||||
desc: '',
|
||||
plugin_id: 'datasource-1',
|
||||
},
|
||||
})
|
||||
|
||||
const normalNode = createNode({
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Writer',
|
||||
desc: '',
|
||||
},
|
||||
})
|
||||
|
||||
const datasource = {
|
||||
plugin_id: 'datasource-1',
|
||||
author: 'Datasource Author',
|
||||
description: {
|
||||
'en-US': 'Datasource description',
|
||||
},
|
||||
}
|
||||
|
||||
const metadataMap = {
|
||||
[BlockEnum.LLM]: {
|
||||
metaData: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
author: 'Dify',
|
||||
description: 'Node description',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const datasourceResult = renderWorkflowHook(() => useNodeMetaData(datasourceNode), {
|
||||
initialStoreState: {
|
||||
dataSourceList: [datasource as never],
|
||||
},
|
||||
hooksStoreProps: {
|
||||
availableNodesMetaData: {
|
||||
nodes: [],
|
||||
nodesMap: metadataMap as never,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const normalResult = renderWorkflowHook(() => useNodeMetaData(normalNode), {
|
||||
hooksStoreProps: {
|
||||
availableNodesMetaData: {
|
||||
nodes: [],
|
||||
nodesMap: metadataMap as never,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(datasourceResult.result.current).toEqual(expect.objectContaining({
|
||||
author: 'Datasource Author',
|
||||
description: 'Datasource description',
|
||||
}))
|
||||
expect(normalResult.result.current).toEqual(expect.objectContaining({
|
||||
author: 'Dify',
|
||||
description: 'Node description',
|
||||
title: 'LLM',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useSetWorkflowVarsWithValue } from '../use-set-workflow-vars-with-value'
|
||||
|
||||
describe('useSetWorkflowVarsWithValue', () => {
|
||||
it('returns fetchInspectVars from hooks store', () => {
|
||||
const fetchInspectVars = vi.fn()
|
||||
|
||||
const { result } = renderWorkflowHook(() => useSetWorkflowVarsWithValue(), {
|
||||
hooksStoreProps: { fetchInspectVars },
|
||||
})
|
||||
|
||||
expect(result.current.fetchInspectVars).toBe(fetchInspectVars)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { ZEN_TOGGLE_EVENT } from '@/app/components/goto-anything/actions/commands/zen'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useShortcuts } from '../use-shortcuts'
|
||||
|
||||
type KeyPressRegistration = {
|
||||
keyFilter: unknown
|
||||
handler: (event: KeyboardEvent) => void
|
||||
options?: {
|
||||
events?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const keyPressRegistrations = vi.hoisted<KeyPressRegistration[]>(() => [])
|
||||
const mockZoomTo = vi.hoisted(() => vi.fn())
|
||||
const mockGetZoom = vi.hoisted(() => vi.fn(() => 1))
|
||||
const mockFitView = vi.hoisted(() => vi.fn())
|
||||
const mockHandleNodesDelete = vi.hoisted(() => vi.fn())
|
||||
const mockHandleEdgeDelete = vi.hoisted(() => vi.fn())
|
||||
const mockHandleNodesCopy = vi.hoisted(() => vi.fn())
|
||||
const mockHandleNodesPaste = vi.hoisted(() => vi.fn())
|
||||
const mockHandleNodesDuplicate = vi.hoisted(() => vi.fn())
|
||||
const mockHandleHistoryBack = vi.hoisted(() => vi.fn())
|
||||
const mockHandleHistoryForward = vi.hoisted(() => vi.fn())
|
||||
const mockDimOtherNodes = vi.hoisted(() => vi.fn())
|
||||
const mockUndimAllNodes = vi.hoisted(() => vi.fn())
|
||||
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
|
||||
const mockHandleModeHand = vi.hoisted(() => vi.fn())
|
||||
const mockHandleModePointer = vi.hoisted(() => vi.fn())
|
||||
const mockHandleLayout = vi.hoisted(() => vi.fn())
|
||||
const mockHandleToggleMaximizeCanvas = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: (keyFilter: unknown, handler: (event: KeyboardEvent) => void, options?: { events?: string[] }) => {
|
||||
keyPressRegistrations.push({ keyFilter, handler, options })
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useReactFlow: () => ({
|
||||
zoomTo: mockZoomTo,
|
||||
getZoom: mockGetZoom,
|
||||
fitView: mockFitView,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('..', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodesCopy: mockHandleNodesCopy,
|
||||
handleNodesPaste: mockHandleNodesPaste,
|
||||
handleNodesDuplicate: mockHandleNodesDuplicate,
|
||||
handleNodesDelete: mockHandleNodesDelete,
|
||||
handleHistoryBack: mockHandleHistoryBack,
|
||||
handleHistoryForward: mockHandleHistoryForward,
|
||||
dimOtherNodes: mockDimOtherNodes,
|
||||
undimAllNodes: mockUndimAllNodes,
|
||||
}),
|
||||
useEdgesInteractions: () => ({
|
||||
handleEdgeDelete: mockHandleEdgeDelete,
|
||||
}),
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
useWorkflowCanvasMaximize: () => ({
|
||||
handleToggleMaximizeCanvas: mockHandleToggleMaximizeCanvas,
|
||||
}),
|
||||
useWorkflowMoveMode: () => ({
|
||||
handleModeHand: mockHandleModeHand,
|
||||
handleModePointer: mockHandleModePointer,
|
||||
}),
|
||||
useWorkflowOrganize: () => ({
|
||||
handleLayout: mockHandleLayout,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../workflow-history-store', () => ({
|
||||
useWorkflowHistoryStore: () => ({
|
||||
shortcutsEnabled: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createKeyboardEvent = (target: HTMLElement = document.body) => ({
|
||||
preventDefault: vi.fn(),
|
||||
target,
|
||||
}) as unknown as KeyboardEvent
|
||||
|
||||
const findRegistration = (matcher: (registration: KeyPressRegistration) => boolean) => {
|
||||
const registration = keyPressRegistrations.find(matcher)
|
||||
expect(registration).toBeDefined()
|
||||
return registration as KeyPressRegistration
|
||||
}
|
||||
|
||||
describe('useShortcuts', () => {
|
||||
beforeEach(() => {
|
||||
keyPressRegistrations.length = 0
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('deletes selected nodes and edges only outside editable inputs', () => {
|
||||
renderWorkflowHook(() => useShortcuts())
|
||||
|
||||
const deleteShortcut = findRegistration(registration =>
|
||||
Array.isArray(registration.keyFilter)
|
||||
&& registration.keyFilter.includes('delete'),
|
||||
)
|
||||
|
||||
const bodyEvent = createKeyboardEvent()
|
||||
deleteShortcut.handler(bodyEvent)
|
||||
|
||||
expect(bodyEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleEdgeDelete).toHaveBeenCalledTimes(1)
|
||||
|
||||
const inputEvent = createKeyboardEvent(document.createElement('input'))
|
||||
deleteShortcut.handler(inputEvent)
|
||||
|
||||
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleEdgeDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs layout and zoom shortcuts through the workflow actions', () => {
|
||||
renderWorkflowHook(() => useShortcuts())
|
||||
|
||||
const layoutShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.o' || registration.keyFilter === 'meta.o')
|
||||
const fitViewShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.1' || registration.keyFilter === 'meta.1')
|
||||
const halfZoomShortcut = findRegistration(registration => registration.keyFilter === 'shift.5')
|
||||
const zoomOutShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.dash' || registration.keyFilter === 'meta.dash')
|
||||
const zoomInShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.equalsign' || registration.keyFilter === 'meta.equalsign')
|
||||
|
||||
layoutShortcut.handler(createKeyboardEvent())
|
||||
fitViewShortcut.handler(createKeyboardEvent())
|
||||
halfZoomShortcut.handler(createKeyboardEvent())
|
||||
zoomOutShortcut.handler(createKeyboardEvent())
|
||||
zoomInShortcut.handler(createKeyboardEvent())
|
||||
|
||||
expect(mockHandleLayout).toHaveBeenCalledTimes(1)
|
||||
expect(mockFitView).toHaveBeenCalledTimes(1)
|
||||
expect(mockZoomTo).toHaveBeenNthCalledWith(1, 0.5)
|
||||
expect(mockZoomTo).toHaveBeenNthCalledWith(2, 0.9)
|
||||
expect(mockZoomTo).toHaveBeenNthCalledWith(3, 1.1)
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('dims on shift down, undims on shift up, and responds to zen toggle events', () => {
|
||||
const { unmount } = renderWorkflowHook(() => useShortcuts())
|
||||
|
||||
const shiftDownShortcut = findRegistration(registration => registration.keyFilter === 'shift' && registration.options?.events?.[0] === 'keydown')
|
||||
const shiftUpShortcut = findRegistration(registration => typeof registration.keyFilter === 'function' && registration.options?.events?.[0] === 'keyup')
|
||||
|
||||
shiftDownShortcut.handler(createKeyboardEvent())
|
||||
shiftUpShortcut.handler({ ...createKeyboardEvent(), key: 'Shift' } as KeyboardEvent)
|
||||
|
||||
expect(mockDimOtherNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockUndimAllNodes).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(ZEN_TOGGLE_EVENT))
|
||||
})
|
||||
expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(ZEN_TOGGLE_EVENT))
|
||||
})
|
||||
expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,209 +0,0 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useEdges, useNodes } from 'reactflow'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus } from '../../types'
|
||||
import { useEdgesInteractionsWithoutSync } from '../use-edges-interactions-without-sync'
|
||||
import { useNodesInteractionsWithoutSync } from '../use-nodes-interactions-without-sync'
|
||||
|
||||
type EdgeRuntimeState = {
|
||||
_sourceRunningStatus?: NodeRunningStatus
|
||||
_targetRunningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
}
|
||||
|
||||
type NodeRuntimeState = {
|
||||
_runningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
}
|
||||
|
||||
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
|
||||
(edge?.data ?? {}) as EdgeRuntimeState
|
||||
|
||||
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
|
||||
(node?.data ?? {}) as NodeRuntimeState
|
||||
|
||||
describe('useEdgesInteractionsWithoutSync', () => {
|
||||
const createFlowNodes = () => [
|
||||
createNode({ id: 'a' }),
|
||||
createNode({ id: 'b' }),
|
||||
createNode({ id: 'c' }),
|
||||
]
|
||||
const createFlowEdges = () => [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
data: {
|
||||
_sourceRunningStatus: NodeRunningStatus.Running,
|
||||
_targetRunningStatus: NodeRunningStatus.Running,
|
||||
_waitingRun: true,
|
||||
},
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'b',
|
||||
target: 'c',
|
||||
data: {
|
||||
_sourceRunningStatus: NodeRunningStatus.Succeeded,
|
||||
_targetRunningStatus: undefined,
|
||||
_waitingRun: false,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const renderEdgesInteractionsHook = () =>
|
||||
renderWorkflowFlowHook(() => ({
|
||||
...useEdgesInteractionsWithoutSync(),
|
||||
edges: useEdges(),
|
||||
}), {
|
||||
nodes: createFlowNodes(),
|
||||
edges: createFlowEdges(),
|
||||
})
|
||||
|
||||
it('should clear running status and waitingRun on all edges', () => {
|
||||
const { result } = renderEdgesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeCancelRunningStatus()
|
||||
})
|
||||
|
||||
return waitFor(() => {
|
||||
result.current.edges.forEach((edge) => {
|
||||
const edgeState = getEdgeRuntimeState(edge)
|
||||
expect(edgeState._sourceRunningStatus).toBeUndefined()
|
||||
expect(edgeState._targetRunningStatus).toBeUndefined()
|
||||
expect(edgeState._waitingRun).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not mutate original edges', () => {
|
||||
const edges = createFlowEdges()
|
||||
const originalData = { ...getEdgeRuntimeState(edges[0]) }
|
||||
const { result } = renderWorkflowFlowHook(() => ({
|
||||
...useEdgesInteractionsWithoutSync(),
|
||||
edges: useEdges(),
|
||||
}), {
|
||||
nodes: createFlowNodes(),
|
||||
edges,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdgeCancelRunningStatus()
|
||||
})
|
||||
|
||||
expect(getEdgeRuntimeState(edges[0])._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNodesInteractionsWithoutSync', () => {
|
||||
const createFlowNodes = () => [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } }),
|
||||
createNode({ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } }),
|
||||
createNode({ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } }),
|
||||
]
|
||||
|
||||
const renderNodesInteractionsHook = () =>
|
||||
renderWorkflowFlowHook(() => ({
|
||||
...useNodesInteractionsWithoutSync(),
|
||||
nodes: useNodes(),
|
||||
}), {
|
||||
nodes: createFlowNodes(),
|
||||
edges: [],
|
||||
})
|
||||
|
||||
describe('handleNodeCancelRunningStatus', () => {
|
||||
it('should clear _runningStatus and _waitingRun on all nodes', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleNodeCancelRunningStatus()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
result.current.nodes.forEach((node) => {
|
||||
const nodeState = getNodeRuntimeState(node)
|
||||
expect(nodeState._runningStatus).toBeUndefined()
|
||||
expect(nodeState._waitingRun).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleCancelAllNodeSuccessStatus', () => {
|
||||
it('should clear _runningStatus only for Succeeded nodes', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancelAllNodeSuccessStatus()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const n1 = result.current.nodes.find(node => node.id === 'n1')
|
||||
const n2 = result.current.nodes.find(node => node.id === 'n2')
|
||||
const n3 = result.current.nodes.find(node => node.id === 'n3')
|
||||
|
||||
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
|
||||
expect(getNodeRuntimeState(n3)._runningStatus).toBe(NodeRunningStatus.Failed)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not modify _waitingRun', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancelAllNodeSuccessStatus()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._waitingRun).toBe(true)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n3'))._waitingRun).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleCancelNodeSuccessStatus', () => {
|
||||
it('should clear _runningStatus and _waitingRun for the specified Succeeded node', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancelNodeSuccessStatus('n2')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const n2 = result.current.nodes.find(node => node.id === 'n2')
|
||||
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
|
||||
expect(getNodeRuntimeState(n2)._waitingRun).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not modify nodes that are not Succeeded', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancelNodeSuccessStatus('n1')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const n1 = result.current.nodes.find(node => node.id === 'n1')
|
||||
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(n1)._waitingRun).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not modify other nodes', async () => {
|
||||
const { result } = renderNodesInteractionsHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancelNodeSuccessStatus('n2')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const n1 = result.current.nodes.find(node => node.id === 'n1')
|
||||
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { WorkflowRunningStatus } from '../../types'
|
||||
import { useWorkflowCanvasMaximize } from '../use-workflow-canvas-maximize'
|
||||
|
||||
const mockEmit = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: mockEmit,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useWorkflowCanvasMaximize', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('toggles maximize state, persists it, and emits the canvas event', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowCanvasMaximize(), {
|
||||
initialStoreState: {
|
||||
maximizeCanvas: false,
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleToggleMaximizeCanvas()
|
||||
|
||||
expect(store.getState().maximizeCanvas).toBe(true)
|
||||
expect(localStorage.getItem('workflow-canvas-maximize')).toBe('true')
|
||||
expect(mockEmit).toHaveBeenCalledWith({
|
||||
type: 'workflow-canvas-maximize',
|
||||
payload: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does nothing while workflow nodes are read-only', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowCanvasMaximize(), {
|
||||
initialStoreState: {
|
||||
maximizeCanvas: false,
|
||||
workflowRunningData: {
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleToggleMaximizeCanvas()
|
||||
|
||||
expect(store.getState().maximizeCanvas).toBe(false)
|
||||
expect(localStorage.getItem('workflow-canvas-maximize')).toBeNull()
|
||||
expect(mockEmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Edge, Node } from '../../types'
|
||||
import { act } from '@testing-library/react'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { useWorkflowHistory, WorkflowHistoryEvent } from '../use-workflow-history'
|
||||
|
||||
const reactFlowState = vi.hoisted(() => ({
|
||||
edges: [] as Edge[],
|
||||
nodes: [] as Node[],
|
||||
}))
|
||||
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
debounce: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
|
||||
return {
|
||||
...actual,
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: () => reactFlowState.nodes,
|
||||
edges: reactFlowState.edges,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const nodes: Node[] = [{
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
},
|
||||
}]
|
||||
|
||||
const edges: Edge[] = [{
|
||||
id: 'edge-1',
|
||||
source: 'node-1',
|
||||
target: 'node-2',
|
||||
type: 'custom',
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.End,
|
||||
},
|
||||
}]
|
||||
|
||||
describe('useWorkflowHistory', () => {
|
||||
beforeEach(() => {
|
||||
reactFlowState.nodes = nodes
|
||||
reactFlowState.edges = edges
|
||||
})
|
||||
|
||||
it('stores the latest workflow graph snapshot for supported events', () => {
|
||||
const { result } = renderWorkflowHook(() => useWorkflowHistory(), {
|
||||
historyStore: {
|
||||
nodes,
|
||||
edges,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: 'node-1' })
|
||||
})
|
||||
|
||||
expect(result.current.store.getState().workflowHistoryEvent).toBe(WorkflowHistoryEvent.NodeAdd)
|
||||
expect(result.current.store.getState().workflowHistoryEventMeta).toEqual({ nodeId: 'node-1' })
|
||||
expect(result.current.store.getState().nodes).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'node-1',
|
||||
data: expect.objectContaining({
|
||||
selected: false,
|
||||
title: 'Start',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
expect(result.current.store.getState().edges).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'edge-1',
|
||||
selected: false,
|
||||
source: 'node-1',
|
||||
target: 'node-2',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('returns translated labels and falls back for unsupported events', () => {
|
||||
const { result } = renderWorkflowHook(() => useWorkflowHistory(), {
|
||||
historyStore: {
|
||||
nodes,
|
||||
edges,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.current.getHistoryLabel(WorkflowHistoryEvent.NodeDelete)).toBe('changeHistory.nodeDelete')
|
||||
expect(result.current.getHistoryLabel('Unknown' as keyof typeof WorkflowHistoryEvent)).toBe('Unknown Event')
|
||||
})
|
||||
|
||||
it('runs registered undo and redo callbacks', () => {
|
||||
const onUndo = vi.fn()
|
||||
const onRedo = vi.fn()
|
||||
|
||||
const { result } = renderWorkflowHook(() => useWorkflowHistory(), {
|
||||
historyStore: {
|
||||
nodes,
|
||||
edges,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.onUndo(onUndo)
|
||||
result.current.onRedo(onRedo)
|
||||
})
|
||||
|
||||
const undoSpy = vi.spyOn(result.current.store.temporal.getState(), 'undo')
|
||||
const redoSpy = vi.spyOn(result.current.store.temporal.getState(), 'redo')
|
||||
|
||||
act(() => {
|
||||
result.current.undo()
|
||||
result.current.redo()
|
||||
})
|
||||
|
||||
expect(undoSpy).toHaveBeenCalled()
|
||||
expect(redoSpy).toHaveBeenCalled()
|
||||
expect(onUndo).toHaveBeenCalled()
|
||||
expect(onRedo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { createLoopNode, createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useWorkflowOrganize } from '../use-workflow-organize'
|
||||
|
||||
const mockSetViewport = vi.hoisted(() => vi.fn())
|
||||
const mockSetNodes = vi.hoisted(() => vi.fn())
|
||||
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
|
||||
const mockSaveStateToHistory = vi.hoisted(() => vi.fn())
|
||||
const mockGetLayoutForChildNodes = vi.hoisted(() => vi.fn())
|
||||
const mockGetLayoutByELK = vi.hoisted(() => vi.fn())
|
||||
|
||||
const runtimeState = vi.hoisted(() => ({
|
||||
nodes: [] as ReturnType<typeof createNode>[],
|
||||
edges: [] as { id: string, source: string, target: string }[],
|
||||
nodesReadOnly: false,
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
Position: {
|
||||
Left: 'left',
|
||||
Right: 'right',
|
||||
Top: 'top',
|
||||
Bottom: 'bottom',
|
||||
},
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: () => runtimeState.nodes,
|
||||
edges: runtimeState.edges,
|
||||
setNodes: mockSetNodes,
|
||||
}),
|
||||
setState: vi.fn(),
|
||||
}),
|
||||
useReactFlow: () => ({
|
||||
setViewport: mockSetViewport,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: () => runtimeState.nodesReadOnly,
|
||||
nodesReadOnly: runtimeState.nodesReadOnly,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: (...args: unknown[]) => mockHandleSyncWorkflowDraft(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow-history', () => ({
|
||||
useWorkflowHistory: () => ({
|
||||
saveStateToHistory: (...args: unknown[]) => mockSaveStateToHistory(...args),
|
||||
}),
|
||||
WorkflowHistoryEvent: {
|
||||
LayoutOrganize: 'LayoutOrganize',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/elk-layout', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../../utils/elk-layout')>()),
|
||||
getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args),
|
||||
getLayoutByELK: (...args: unknown[]) => mockGetLayoutByELK(...args),
|
||||
}))
|
||||
|
||||
describe('useWorkflowOrganize', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
runtimeState.nodesReadOnly = false
|
||||
runtimeState.nodes = []
|
||||
runtimeState.edges = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resizes containers, lays out nodes, and syncs draft when editable', async () => {
|
||||
runtimeState.nodes = [
|
||||
createLoopNode({
|
||||
id: 'loop-node',
|
||||
width: 200,
|
||||
height: 160,
|
||||
}),
|
||||
createNode({
|
||||
id: 'loop-child',
|
||||
parentId: 'loop-node',
|
||||
position: { x: 20, y: 20 },
|
||||
width: 100,
|
||||
height: 60,
|
||||
}),
|
||||
createNode({
|
||||
id: 'top-node',
|
||||
position: { x: 400, y: 0 },
|
||||
}),
|
||||
]
|
||||
runtimeState.edges = []
|
||||
mockGetLayoutForChildNodes.mockResolvedValue({
|
||||
bounds: { minX: 0, minY: 0, maxX: 320, maxY: 220 },
|
||||
nodes: new Map([
|
||||
['loop-child', { x: 40, y: 60, width: 100, height: 60 }],
|
||||
]),
|
||||
})
|
||||
mockGetLayoutByELK.mockResolvedValue({
|
||||
nodes: new Map([
|
||||
['loop-node', { x: 10, y: 20, width: 360, height: 260, layer: 0 }],
|
||||
['top-node', { x: 500, y: 30, width: 240, height: 100, layer: 0 }],
|
||||
]),
|
||||
})
|
||||
|
||||
const { result } = renderWorkflowHook(() => useWorkflowOrganize())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleLayout()
|
||||
})
|
||||
act(() => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(mockSetNodes).toHaveBeenCalledTimes(1)
|
||||
const nextNodes = mockSetNodes.mock.calls[0][0]
|
||||
expect(nextNodes.find((node: { id: string }) => node.id === 'loop-node')).toEqual(expect.objectContaining({
|
||||
width: expect.any(Number),
|
||||
height: expect.any(Number),
|
||||
position: { x: 10, y: 20 },
|
||||
}))
|
||||
expect(nextNodes.find((node: { id: string }) => node.id === 'loop-child')).toEqual(expect.objectContaining({
|
||||
position: { x: 100, y: 120 },
|
||||
}))
|
||||
expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 0.7 })
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('LayoutOrganize')
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips layout when nodes are read-only', async () => {
|
||||
runtimeState.nodesReadOnly = true
|
||||
runtimeState.nodes = [createNode({ id: 'n1' })]
|
||||
|
||||
const { result } = renderWorkflowHook(() => useWorkflowOrganize())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleLayout()
|
||||
})
|
||||
|
||||
expect(mockGetLayoutForChildNodes).not.toHaveBeenCalled()
|
||||
expect(mockGetLayoutByELK).not.toHaveBeenCalled()
|
||||
expect(mockSetNodes).not.toHaveBeenCalled()
|
||||
expect(mockSetViewport).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { ControlMode } from '../../types'
|
||||
import {
|
||||
useWorkflowInteractions,
|
||||
useWorkflowMoveMode,
|
||||
} from '../use-workflow-panel-interactions'
|
||||
|
||||
const mockHandleSelectionCancel = vi.hoisted(() => vi.fn())
|
||||
const mockHandleNodeCancelRunningStatus = vi.hoisted(() => vi.fn())
|
||||
const mockHandleEdgeCancelRunningStatus = vi.hoisted(() => vi.fn())
|
||||
|
||||
const runtimeState = vi.hoisted(() => ({
|
||||
nodesReadOnly: false,
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: () => runtimeState.nodesReadOnly,
|
||||
nodesReadOnly: runtimeState.nodesReadOnly,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-selection-interactions', () => ({
|
||||
useSelectionInteractions: () => ({
|
||||
handleSelectionCancel: (...args: unknown[]) => mockHandleSelectionCancel(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-interactions-without-sync', () => ({
|
||||
useNodesInteractionsWithoutSync: () => ({
|
||||
handleNodeCancelRunningStatus: (...args: unknown[]) => mockHandleNodeCancelRunningStatus(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-edges-interactions-without-sync', () => ({
|
||||
useEdgesInteractionsWithoutSync: () => ({
|
||||
handleEdgeCancelRunningStatus: (...args: unknown[]) => mockHandleEdgeCancelRunningStatus(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useWorkflowInteractions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
runtimeState.nodesReadOnly = false
|
||||
})
|
||||
|
||||
it('closes the debug panel and clears running state', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowInteractions(), {
|
||||
initialStoreState: {
|
||||
showDebugAndPreviewPanel: true,
|
||||
workflowRunningData: { task_id: 'task-1' } as never,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancelDebugAndPreviewPanel()
|
||||
})
|
||||
|
||||
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
|
||||
expect(store.getState().workflowRunningData).toBeUndefined()
|
||||
expect(mockHandleNodeCancelRunningStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleEdgeCancelRunningStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowMoveMode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
runtimeState.nodesReadOnly = false
|
||||
})
|
||||
|
||||
it('switches between hand and pointer modes when editable', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowMoveMode(), {
|
||||
initialStoreState: {
|
||||
controlMode: ControlMode.Pointer,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleModeHand()
|
||||
})
|
||||
|
||||
expect(store.getState().controlMode).toBe(ControlMode.Hand)
|
||||
expect(mockHandleSelectionCancel).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
result.current.handleModePointer()
|
||||
})
|
||||
|
||||
expect(store.getState().controlMode).toBe(ControlMode.Pointer)
|
||||
})
|
||||
|
||||
it('does not switch modes when nodes are read-only', () => {
|
||||
runtimeState.nodesReadOnly = true
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowMoveMode(), {
|
||||
initialStoreState: {
|
||||
controlMode: ControlMode.Pointer,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleModeHand()
|
||||
result.current.handleModePointer()
|
||||
})
|
||||
|
||||
expect(store.getState().controlMode).toBe(ControlMode.Pointer)
|
||||
expect(mockHandleSelectionCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft'
|
||||
|
||||
describe('useWorkflowRefreshDraft', () => {
|
||||
it('returns handleRefreshWorkflowDraft from hooks store', () => {
|
||||
const handleRefreshWorkflowDraft = vi.fn()
|
||||
|
||||
const { result } = renderWorkflowHook(() => useWorkflowRefreshDraft(), {
|
||||
hooksStoreProps: { handleRefreshWorkflowDraft },
|
||||
})
|
||||
|
||||
expect(result.current.handleRefreshWorkflowDraft).toBe(handleRefreshWorkflowDraft)
|
||||
})
|
||||
})
|
||||
@@ -1,242 +0,0 @@
|
||||
import type {
|
||||
AgentLogResponse,
|
||||
HumanInputFormFilledResponse,
|
||||
HumanInputFormTimeoutResponse,
|
||||
TextChunkResponse,
|
||||
TextReplaceResponse,
|
||||
WorkflowFinishedResponse,
|
||||
} from '@/types/workflow'
|
||||
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { WorkflowRunningStatus } from '../../types'
|
||||
import { useWorkflowAgentLog } from '../use-workflow-run-event/use-workflow-agent-log'
|
||||
import { useWorkflowFailed } from '../use-workflow-run-event/use-workflow-failed'
|
||||
import { useWorkflowFinished } from '../use-workflow-run-event/use-workflow-finished'
|
||||
import { useWorkflowNodeHumanInputFormFilled } from '../use-workflow-run-event/use-workflow-node-human-input-form-filled'
|
||||
import { useWorkflowNodeHumanInputFormTimeout } from '../use-workflow-run-event/use-workflow-node-human-input-form-timeout'
|
||||
import { useWorkflowPaused } from '../use-workflow-run-event/use-workflow-paused'
|
||||
import { useWorkflowTextChunk } from '../use-workflow-run-event/use-workflow-text-chunk'
|
||||
import { useWorkflowTextReplace } from '../use-workflow-run-event/use-workflow-text-replace'
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader/utils', () => ({
|
||||
getFilesInLogs: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
describe('useWorkflowFailed', () => {
|
||||
it('should set status to Failed', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowFailed(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowFailed()
|
||||
|
||||
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Failed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowPaused', () => {
|
||||
it('should set status to Paused', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowPaused(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowPaused()
|
||||
|
||||
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Paused)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowTextChunk', () => {
|
||||
it('should append text and activate result tab', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowTextChunk(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({ resultText: 'Hello' }),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowTextChunk({ data: { text: ' World' } } as TextChunkResponse)
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.resultText).toBe('Hello World')
|
||||
expect(state.resultTabActive).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowTextReplace', () => {
|
||||
it('should replace resultText', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowTextReplace(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({ resultText: 'old text' }),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowTextReplace({ data: { text: 'new text' } } as TextReplaceResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.resultText).toBe('new text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowFinished', () => {
|
||||
it('should merge data into result and activate result tab for single string output', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowFinished({
|
||||
data: { status: 'succeeded', outputs: { answer: 'hello' } },
|
||||
} as WorkflowFinishedResponse)
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.result.status).toBe('succeeded')
|
||||
expect(state.resultTabActive).toBe(true)
|
||||
expect(state.resultText).toBe('hello')
|
||||
})
|
||||
|
||||
it('should not activate result tab for multi-key outputs', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowFinished({
|
||||
data: { status: 'succeeded', outputs: { a: 'hello', b: 'world' } },
|
||||
} as WorkflowFinishedResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.resultTabActive).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowAgentLog', () => {
|
||||
it('should create agent_log array when execution_metadata has no agent_log', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1', execution_metadata: {} }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm1' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
const trace = store.getState().workflowRunningData!.tracing![0]
|
||||
expect(trace.execution_metadata!.agent_log).toHaveLength(1)
|
||||
expect(trace.execution_metadata!.agent_log![0].message_id).toBe('m1')
|
||||
})
|
||||
|
||||
it('should append to existing agent_log', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{
|
||||
node_id: 'n1',
|
||||
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] },
|
||||
}],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm2' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should update existing log entry by message_id', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{
|
||||
node_id: 'n1',
|
||||
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] },
|
||||
}],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm1', text: 'new' },
|
||||
} as unknown as AgentLogResponse)
|
||||
|
||||
const log = store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log!
|
||||
expect(log).toHaveLength(1)
|
||||
expect((log[0] as unknown as { text: string }).text).toBe('new')
|
||||
})
|
||||
|
||||
it('should create execution_metadata when it does not exist', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1' }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm1' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeHumanInputFormFilled', () => {
|
||||
it('should remove form from humanInputFormDataList and add to humanInputFilledFormDataList', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
humanInputFormDataList: [
|
||||
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeHumanInputFormFilled({
|
||||
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
|
||||
} as HumanInputFormFilledResponse)
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.humanInputFormDataList).toHaveLength(0)
|
||||
expect(state.humanInputFilledFormDataList).toHaveLength(1)
|
||||
expect(state.humanInputFilledFormDataList![0].node_id).toBe('n1')
|
||||
})
|
||||
|
||||
it('should create humanInputFilledFormDataList when it does not exist', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
humanInputFormDataList: [
|
||||
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeHumanInputFormFilled({
|
||||
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
|
||||
} as HumanInputFormFilledResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.humanInputFilledFormDataList).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeHumanInputFormTimeout', () => {
|
||||
it('should set expiration_time on the matching form', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormTimeout(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
humanInputFormDataList: [
|
||||
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '', expiration_time: 0 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeHumanInputFormTimeout({
|
||||
data: { node_id: 'n1', node_title: 'Node 1', expiration_time: 1000 },
|
||||
} as HumanInputFormTimeoutResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.humanInputFormDataList![0].expiration_time).toBe(1000)
|
||||
})
|
||||
})
|
||||
@@ -1,336 +0,0 @@
|
||||
import type { WorkflowRunningData } from '../../types'
|
||||
import type {
|
||||
IterationFinishedResponse,
|
||||
IterationNextResponse,
|
||||
LoopFinishedResponse,
|
||||
LoopNextResponse,
|
||||
NodeFinishedResponse,
|
||||
WorkflowStartedResponse,
|
||||
} from '@/types/workflow'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useEdges, useNodes } from 'reactflow'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { baseRunningData, renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { DEFAULT_ITER_TIMES } from '../../constants'
|
||||
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
|
||||
import { useWorkflowNodeFinished } from '../use-workflow-run-event/use-workflow-node-finished'
|
||||
import { useWorkflowNodeIterationFinished } from '../use-workflow-run-event/use-workflow-node-iteration-finished'
|
||||
import { useWorkflowNodeIterationNext } from '../use-workflow-run-event/use-workflow-node-iteration-next'
|
||||
import { useWorkflowNodeLoopFinished } from '../use-workflow-run-event/use-workflow-node-loop-finished'
|
||||
import { useWorkflowNodeLoopNext } from '../use-workflow-run-event/use-workflow-node-loop-next'
|
||||
import { useWorkflowNodeRetry } from '../use-workflow-run-event/use-workflow-node-retry'
|
||||
import { useWorkflowStarted } from '../use-workflow-run-event/use-workflow-started'
|
||||
|
||||
type NodeRuntimeState = {
|
||||
_waitingRun?: boolean
|
||||
_runningStatus?: NodeRunningStatus
|
||||
_retryIndex?: number
|
||||
_iterationIndex?: number
|
||||
_loopIndex?: number
|
||||
_runningBranchId?: string
|
||||
}
|
||||
|
||||
type EdgeRuntimeState = {
|
||||
_sourceRunningStatus?: NodeRunningStatus
|
||||
_targetRunningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
}
|
||||
|
||||
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
|
||||
(node?.data ?? {}) as NodeRuntimeState
|
||||
|
||||
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
|
||||
(edge?.data ?? {}) as EdgeRuntimeState
|
||||
|
||||
function createRunNodes() {
|
||||
return [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
width: 200,
|
||||
height: 80,
|
||||
data: { _waitingRun: false },
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function createRunEdges() {
|
||||
return [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n0',
|
||||
target: 'n1',
|
||||
data: {},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function renderRunEventHook<T extends Record<string, unknown>>(
|
||||
useHook: () => T,
|
||||
options?: {
|
||||
nodes?: ReturnType<typeof createRunNodes>
|
||||
edges?: ReturnType<typeof createRunEdges>
|
||||
initialStoreState?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
const { nodes = createRunNodes(), edges = createRunEdges(), initialStoreState } = options ?? {}
|
||||
|
||||
return renderWorkflowFlowHook(() => ({
|
||||
...useHook(),
|
||||
nodes: useNodes(),
|
||||
edges: useEdges(),
|
||||
}), {
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps: { fitView: false },
|
||||
initialStoreState,
|
||||
})
|
||||
}
|
||||
|
||||
describe('useWorkflowStarted', () => {
|
||||
it('should initialize workflow running data and reset nodes/edges', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStarted({
|
||||
task_id: 'task-2',
|
||||
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
|
||||
} as WorkflowStartedResponse)
|
||||
})
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.task_id).toBe('task-2')
|
||||
expect(state.result.status).toBe(WorkflowRunningStatus.Running)
|
||||
expect(state.resultText).toBe('')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(true)
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBeUndefined()
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._sourceRunningStatus).toBeUndefined()
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBeUndefined()
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should resume from Paused without resetting nodes/edges', () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
result: { status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStarted({
|
||||
task_id: 'task-2',
|
||||
data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
|
||||
} as WorkflowStartedResponse)
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeFinished', () => {
|
||||
it('should update tracing and node running status', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeFinished({
|
||||
data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
} as NodeFinishedResponse)
|
||||
})
|
||||
|
||||
const trace = store.getState().workflowRunningData!.tracing![0]
|
||||
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set _runningBranchId for IfElse node', async () => {
|
||||
const { result } = renderRunEventHook(() => useWorkflowNodeFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeFinished({
|
||||
data: {
|
||||
id: 'trace-1',
|
||||
node_id: 'n1',
|
||||
node_type: 'if-else',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
outputs: { selected_case_id: 'branch-a' },
|
||||
},
|
||||
} as unknown as NodeFinishedResponse)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBe('branch-a')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeRetry', () => {
|
||||
it('should push retry data to tracing and update _retryIndex', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeRetry(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeRetry({
|
||||
data: { node_id: 'n1', retry_index: 2 },
|
||||
} as NodeFinishedResponse)
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing).toHaveLength(1)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._retryIndex).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeIterationNext', () => {
|
||||
it('should set _iterationIndex and increment iterTimes', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationNext(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData(),
|
||||
iterTimes: 3,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeIterationNext({
|
||||
data: { node_id: 'n1' },
|
||||
} as IterationNextResponse)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._iterationIndex).toBe(3)
|
||||
})
|
||||
expect(store.getState().iterTimes).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeIterationFinished', () => {
|
||||
it('should update tracing, reset iterTimes, update node status and edges', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
}),
|
||||
iterTimes: 10,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeIterationFinished({
|
||||
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
} as IterationFinishedResponse)
|
||||
})
|
||||
|
||||
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeLoopNext', () => {
|
||||
it('should set _loopIndex and reset child nodes to waiting', async () => {
|
||||
const { result } = renderRunEventHook(() => useWorkflowNodeLoopNext(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: {} }),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
position: { x: 300, y: 0 },
|
||||
parentId: 'n1',
|
||||
data: { _waitingRun: false },
|
||||
}),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeLoopNext({
|
||||
data: { node_id: 'n1', index: 5 },
|
||||
} as LoopNextResponse)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._loopIndex).toBe(5)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._waitingRun).toBe(true)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Waiting)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeLoopFinished', () => {
|
||||
it('should update tracing, node status and edges', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeLoopFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeLoopFinished({
|
||||
data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
} as LoopFinishedResponse)
|
||||
})
|
||||
|
||||
const trace = store.getState().workflowRunningData!.tracing![0]
|
||||
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,331 +0,0 @@
|
||||
import type {
|
||||
HumanInputRequiredResponse,
|
||||
IterationStartedResponse,
|
||||
LoopStartedResponse,
|
||||
NodeStartedResponse,
|
||||
} from '@/types/workflow'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useEdges, useNodes, useStoreApi } from 'reactflow'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { baseRunningData, renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { DEFAULT_ITER_TIMES } from '../../constants'
|
||||
import { NodeRunningStatus } from '../../types'
|
||||
import { useWorkflowNodeHumanInputRequired } from '../use-workflow-run-event/use-workflow-node-human-input-required'
|
||||
import { useWorkflowNodeIterationStarted } from '../use-workflow-run-event/use-workflow-node-iteration-started'
|
||||
import { useWorkflowNodeLoopStarted } from '../use-workflow-run-event/use-workflow-node-loop-started'
|
||||
import { useWorkflowNodeStarted } from '../use-workflow-run-event/use-workflow-node-started'
|
||||
|
||||
type NodeRuntimeState = {
|
||||
_waitingRun?: boolean
|
||||
_runningStatus?: NodeRunningStatus
|
||||
_iterationLength?: number
|
||||
_loopLength?: number
|
||||
}
|
||||
|
||||
type EdgeRuntimeState = {
|
||||
_sourceRunningStatus?: NodeRunningStatus
|
||||
_targetRunningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
}
|
||||
|
||||
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
|
||||
(node?.data ?? {}) as NodeRuntimeState
|
||||
|
||||
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
|
||||
(edge?.data ?? {}) as EdgeRuntimeState
|
||||
|
||||
const containerParams = { clientWidth: 1200, clientHeight: 800 }
|
||||
|
||||
function createViewportNodes() {
|
||||
return [
|
||||
createNode({
|
||||
id: 'n0',
|
||||
width: 200,
|
||||
height: 80,
|
||||
data: { _runningStatus: NodeRunningStatus.Succeeded },
|
||||
}),
|
||||
createNode({
|
||||
id: 'n1',
|
||||
position: { x: 100, y: 50 },
|
||||
width: 200,
|
||||
height: 80,
|
||||
data: { _waitingRun: true },
|
||||
}),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
position: { x: 400, y: 50 },
|
||||
width: 200,
|
||||
height: 80,
|
||||
parentId: 'n1',
|
||||
data: { _waitingRun: true },
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function createViewportEdges() {
|
||||
return [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n0',
|
||||
target: 'n1',
|
||||
sourceHandle: 'source',
|
||||
data: {},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function renderViewportHook<T extends Record<string, unknown>>(
|
||||
useHook: () => T,
|
||||
options?: {
|
||||
nodes?: ReturnType<typeof createViewportNodes>
|
||||
edges?: ReturnType<typeof createViewportEdges>
|
||||
initialStoreState?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
const {
|
||||
nodes = createViewportNodes(),
|
||||
edges = createViewportEdges(),
|
||||
initialStoreState,
|
||||
} = options ?? {}
|
||||
|
||||
return renderWorkflowFlowHook(() => ({
|
||||
...useHook(),
|
||||
nodes: useNodes(),
|
||||
edges: useEdges(),
|
||||
reactFlowStore: useStoreApi(),
|
||||
}), {
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps: { fitView: false },
|
||||
initialStoreState,
|
||||
})
|
||||
}
|
||||
|
||||
describe('useWorkflowNodeStarted', () => {
|
||||
it('should push to tracing, set node running, and adjust viewport for root node', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing).toHaveLength(1)
|
||||
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
|
||||
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(200)
|
||||
expect(transform[1]).toBe(310)
|
||||
expect(transform[2]).toBe(1)
|
||||
|
||||
const node = result.current.nodes.find(item => item.id === 'n1')
|
||||
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not adjust viewport for child node (has parentId)', async () => {
|
||||
const { result } = renderViewportHook(() => useWorkflowNodeStarted(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n2' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(0)
|
||||
expect(transform[1]).toBe(0)
|
||||
expect(transform[2]).toBe(1)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update existing tracing entry if node_id exists at non-zero index', () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [
|
||||
{ node_id: 'n0', status: NodeRunningStatus.Succeeded },
|
||||
{ node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing).toHaveLength(2)
|
||||
expect(tracing[1].status).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeIterationStarted', () => {
|
||||
it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeIterationStarted(), {
|
||||
nodes: createViewportNodes().slice(0, 2),
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData(),
|
||||
iterTimes: 99,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeIterationStarted(
|
||||
{ data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
|
||||
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
|
||||
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(200)
|
||||
expect(transform[1]).toBe(310)
|
||||
expect(transform[2]).toBe(1)
|
||||
|
||||
const node = result.current.nodes.find(item => item.id === 'n1')
|
||||
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(node)._iterationLength).toBe(10)
|
||||
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeLoopStarted', () => {
|
||||
it('should push to tracing, set viewport, and update node with _loopLength', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeLoopStarted(), {
|
||||
nodes: createViewportNodes().slice(0, 2),
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeLoopStarted(
|
||||
{ data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Running)
|
||||
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(200)
|
||||
expect(transform[1]).toBe(310)
|
||||
expect(transform[2]).toBe(1)
|
||||
|
||||
const node = result.current.nodes.find(item => item.id === 'n1')
|
||||
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(node)._loopLength).toBe(5)
|
||||
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeHumanInputRequired', () => {
|
||||
it('should create humanInputFormDataList and set tracing/node to Paused', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
|
||||
} as HumanInputRequiredResponse)
|
||||
})
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.humanInputFormDataList).toHaveLength(1)
|
||||
expect(state.humanInputFormDataList![0].form_id).toBe('f1')
|
||||
expect(state.tracing![0].status).toBe(NodeRunningStatus.Paused)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n1'))._runningStatus).toBe(NodeRunningStatus.Paused)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update existing form entry for same node_id', () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
humanInputFormDataList: [
|
||||
{ node_id: 'n1', form_id: 'old', node_title: 'Node 1', form_content: 'old' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
|
||||
} as HumanInputRequiredResponse)
|
||||
})
|
||||
|
||||
const formList = store.getState().workflowRunningData!.humanInputFormDataList!
|
||||
expect(formList).toHaveLength(1)
|
||||
expect(formList[0].form_id).toBe('new')
|
||||
})
|
||||
|
||||
it('should append new form entry for different node_id', () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n2', status: NodeRunningStatus.Running }],
|
||||
humanInputFormDataList: [
|
||||
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
|
||||
} as HumanInputRequiredResponse)
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useWorkflowRun } from '../use-workflow-run'
|
||||
|
||||
describe('useWorkflowRun', () => {
|
||||
it('returns workflow run handlers from hooks store', () => {
|
||||
const handlers = {
|
||||
handleBackupDraft: vi.fn(),
|
||||
handleLoadBackupDraft: vi.fn(),
|
||||
handleRestoreFromPublishedWorkflow: vi.fn(),
|
||||
handleRun: vi.fn(),
|
||||
handleStopRun: vi.fn(),
|
||||
}
|
||||
|
||||
const { result } = renderWorkflowHook(() => useWorkflowRun(), {
|
||||
hooksStoreProps: handlers,
|
||||
})
|
||||
|
||||
expect(result.current.handleBackupDraft).toBe(handlers.handleBackupDraft)
|
||||
expect(result.current.handleLoadBackupDraft).toBe(handlers.handleLoadBackupDraft)
|
||||
expect(result.current.handleRestoreFromPublishedWorkflow).toBe(handlers.handleRestoreFromPublishedWorkflow)
|
||||
expect(result.current.handleRun).toBe(handlers.handleRun)
|
||||
expect(result.current.handleStopRun).toBe(handlers.handleStopRun)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { CommonNodeType, Node, ToolWithProvider } from '../../types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { useWorkflowSearch } from '../use-workflow-search'
|
||||
|
||||
const mockHandleNodeSelect = vi.hoisted(() => vi.fn())
|
||||
const runtimeNodes = vi.hoisted(() => [] as Node[])
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useNodes: () => runtimeNodes,
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-interactions', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => ({
|
||||
data: [{
|
||||
id: 'provider-1',
|
||||
icon: 'tool-icon',
|
||||
tools: [],
|
||||
}] satisfies Partial<ToolWithProvider>[],
|
||||
}),
|
||||
useAllCustomTools: () => ({ data: [] }),
|
||||
useAllWorkflowTools: () => ({ data: [] }),
|
||||
useAllMCPTools: () => ({ data: [] }),
|
||||
}))
|
||||
|
||||
const createNode = (overrides: Partial<Node> = {}): Node => ({
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Writer',
|
||||
desc: 'Draft content',
|
||||
} as CommonNodeType,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useWorkflowSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
runtimeNodes.length = 0
|
||||
workflowNodesAction.searchFn = undefined
|
||||
})
|
||||
|
||||
it('registers workflow node search results with tool icons and llm metadata scoring', async () => {
|
||||
runtimeNodes.push(
|
||||
createNode({
|
||||
id: 'llm-1',
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'Writer',
|
||||
desc: 'Draft content',
|
||||
model: {
|
||||
provider: 'openai',
|
||||
name: 'gpt-4o',
|
||||
mode: 'chat',
|
||||
},
|
||||
} as CommonNodeType,
|
||||
}),
|
||||
createNode({
|
||||
id: 'tool-1',
|
||||
data: {
|
||||
type: BlockEnum.Tool,
|
||||
title: 'Google Search',
|
||||
desc: 'Search the web',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_id: 'provider-1',
|
||||
} as CommonNodeType,
|
||||
}),
|
||||
createNode({
|
||||
id: 'internal-start',
|
||||
data: {
|
||||
type: BlockEnum.IterationStart,
|
||||
title: 'Internal Start',
|
||||
desc: '',
|
||||
} as CommonNodeType,
|
||||
}),
|
||||
)
|
||||
|
||||
const { unmount } = renderHook(() => useWorkflowSearch())
|
||||
|
||||
const llmResults = await workflowNodesAction.search('', 'gpt')
|
||||
expect(llmResults.map(item => item.id)).toEqual(['llm-1'])
|
||||
expect(llmResults[0]?.title).toBe('Writer')
|
||||
|
||||
const toolResults = await workflowNodesAction.search('', 'search')
|
||||
expect(toolResults.map(item => item.id)).toEqual(['tool-1'])
|
||||
expect(toolResults[0]?.description).toBe('Search the web')
|
||||
|
||||
unmount()
|
||||
|
||||
expect(workflowNodesAction.searchFn).toBeUndefined()
|
||||
})
|
||||
|
||||
it('binds the node selection listener to handleNodeSelect', () => {
|
||||
const { unmount } = renderHook(() => useWorkflowSearch())
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new CustomEvent('workflow:select-node', {
|
||||
detail: {
|
||||
nodeId: 'node-42',
|
||||
focus: false,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-42')
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useWorkflowStartRun } from '../use-workflow-start-run'
|
||||
|
||||
describe('useWorkflowStartRun', () => {
|
||||
it('returns start-run handlers from hooks store', () => {
|
||||
const handlers = {
|
||||
handleStartWorkflowRun: vi.fn(),
|
||||
handleWorkflowStartRunInWorkflow: vi.fn(),
|
||||
handleWorkflowStartRunInChatflow: vi.fn(),
|
||||
handleWorkflowTriggerScheduleRunInWorkflow: vi.fn(),
|
||||
handleWorkflowTriggerWebhookRunInWorkflow: vi.fn(),
|
||||
handleWorkflowTriggerPluginRunInWorkflow: vi.fn(),
|
||||
handleWorkflowRunAllTriggersInWorkflow: vi.fn(),
|
||||
}
|
||||
|
||||
const { result } = renderWorkflowHook(() => useWorkflowStartRun(), {
|
||||
hooksStoreProps: handlers,
|
||||
})
|
||||
|
||||
expect(result.current.handleStartWorkflowRun).toBe(handlers.handleStartWorkflowRun)
|
||||
expect(result.current.handleWorkflowStartRunInWorkflow).toBe(handlers.handleWorkflowStartRunInWorkflow)
|
||||
expect(result.current.handleWorkflowStartRunInChatflow).toBe(handlers.handleWorkflowStartRunInChatflow)
|
||||
expect(result.current.handleWorkflowTriggerScheduleRunInWorkflow).toBe(handlers.handleWorkflowTriggerScheduleRunInWorkflow)
|
||||
expect(result.current.handleWorkflowTriggerWebhookRunInWorkflow).toBe(handlers.handleWorkflowTriggerWebhookRunInWorkflow)
|
||||
expect(result.current.handleWorkflowTriggerPluginRunInWorkflow).toBe(handlers.handleWorkflowTriggerPluginRunInWorkflow)
|
||||
expect(result.current.handleWorkflowRunAllTriggersInWorkflow).toBe(handlers.handleWorkflowRunAllTriggersInWorkflow)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useWorkflowUpdate } from '../use-workflow-update'
|
||||
|
||||
const mockSetViewport = vi.hoisted(() => vi.fn())
|
||||
const mockEventEmit = vi.hoisted(() => vi.fn())
|
||||
const mockInitialNodes = vi.hoisted(() => vi.fn((nodes: unknown[], _edges: unknown[]) => nodes))
|
||||
const mockInitialEdges = vi.hoisted(() => vi.fn((edges: unknown[], _nodes: unknown[]) => edges))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
Position: {
|
||||
Left: 'left',
|
||||
Right: 'right',
|
||||
Top: 'top',
|
||||
Bottom: 'bottom',
|
||||
},
|
||||
useReactFlow: () => ({
|
||||
setViewport: mockSetViewport,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: (...args: unknown[]) => mockEventEmit(...args),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../../utils')>()),
|
||||
initialNodes: (nodes: unknown[], edges: unknown[]) => mockInitialNodes(nodes, edges),
|
||||
initialEdges: (edges: unknown[], nodes: unknown[]) => mockInitialEdges(edges, nodes),
|
||||
}))
|
||||
|
||||
describe('useWorkflowUpdate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('emits initialized data and only sets a valid viewport', () => {
|
||||
const { result } = renderWorkflowHook(() => useWorkflowUpdate())
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateWorkflowCanvas({
|
||||
nodes: [createNode({ id: 'n1' })],
|
||||
edges: [],
|
||||
viewport: { x: 10, y: 20, zoom: 0.5 },
|
||||
} as never)
|
||||
result.current.handleUpdateWorkflowCanvas({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 'bad' } as never,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockInitialNodes).toHaveBeenCalled()
|
||||
expect(mockInitialEdges).toHaveBeenCalled()
|
||||
expect(mockEventEmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'WORKFLOW_DATA_UPDATE',
|
||||
}))
|
||||
expect(mockSetViewport).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetViewport).toHaveBeenCalledWith({ x: 10, y: 20, zoom: 0.5 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useWorkflowZoom } from '../use-workflow-zoom'
|
||||
|
||||
const {
|
||||
mockFitView,
|
||||
mockZoomIn,
|
||||
mockZoomOut,
|
||||
mockZoomTo,
|
||||
mockHandleSyncWorkflowDraft,
|
||||
runtimeState,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFitView: vi.fn(),
|
||||
mockZoomIn: vi.fn(),
|
||||
mockZoomOut: vi.fn(),
|
||||
mockZoomTo: vi.fn(),
|
||||
mockHandleSyncWorkflowDraft: vi.fn(),
|
||||
runtimeState: {
|
||||
workflowReadOnly: false,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useReactFlow: () => ({
|
||||
fitView: mockFitView,
|
||||
zoomIn: mockZoomIn,
|
||||
zoomOut: mockZoomOut,
|
||||
zoomTo: mockZoomTo,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: (...args: unknown[]) => mockHandleSyncWorkflowDraft(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow', () => ({
|
||||
useWorkflowReadOnly: () => ({
|
||||
getWorkflowReadOnly: () => runtimeState.workflowReadOnly,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useWorkflowZoom', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
runtimeState.workflowReadOnly = false
|
||||
})
|
||||
|
||||
it('runs zoom actions and syncs the workflow draft when editable', () => {
|
||||
const { result } = renderHook(() => useWorkflowZoom())
|
||||
|
||||
act(() => {
|
||||
result.current.handleFitView()
|
||||
result.current.handleBackToOriginalSize()
|
||||
result.current.handleSizeToHalf()
|
||||
result.current.handleZoomOut()
|
||||
result.current.handleZoomIn()
|
||||
})
|
||||
|
||||
expect(mockFitView).toHaveBeenCalledTimes(1)
|
||||
expect(mockZoomTo).toHaveBeenCalledWith(1)
|
||||
expect(mockZoomTo).toHaveBeenCalledWith(0.5)
|
||||
expect(mockZoomOut).toHaveBeenCalledTimes(1)
|
||||
expect(mockZoomIn).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('blocks zoom actions when the workflow is read-only', () => {
|
||||
runtimeState.workflowReadOnly = true
|
||||
const { result } = renderHook(() => useWorkflowZoom())
|
||||
|
||||
act(() => {
|
||||
result.current.handleFitView()
|
||||
result.current.handleBackToOriginalSize()
|
||||
result.current.handleSizeToHalf()
|
||||
result.current.handleZoomOut()
|
||||
result.current.handleZoomIn()
|
||||
})
|
||||
|
||||
expect(mockFitView).not.toHaveBeenCalled()
|
||||
expect(mockZoomTo).not.toHaveBeenCalled()
|
||||
expect(mockZoomOut).not.toHaveBeenCalled()
|
||||
expect(mockZoomIn).not.toHaveBeenCalled()
|
||||
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { WorkflowRunningData } from '../../../types'
|
||||
import type {
|
||||
IterationFinishedResponse,
|
||||
IterationNextResponse,
|
||||
LoopFinishedResponse,
|
||||
LoopNextResponse,
|
||||
NodeFinishedResponse,
|
||||
NodeStartedResponse,
|
||||
WorkflowStartedResponse,
|
||||
} from '@/types/workflow'
|
||||
import { useEdges, useNodes, useStoreApi } from 'reactflow'
|
||||
import { createEdge, createNode } from '../../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowHook } from '../../../__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus, WorkflowRunningStatus } from '../../../types'
|
||||
|
||||
type NodeRuntimeState = {
|
||||
_waitingRun?: boolean
|
||||
_runningStatus?: NodeRunningStatus
|
||||
_retryIndex?: number
|
||||
_iterationIndex?: number
|
||||
_iterationLength?: number
|
||||
_loopIndex?: number
|
||||
_loopLength?: number
|
||||
_runningBranchId?: string
|
||||
}
|
||||
|
||||
type EdgeRuntimeState = {
|
||||
_sourceRunningStatus?: NodeRunningStatus
|
||||
_targetRunningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
}
|
||||
|
||||
export const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
|
||||
(node?.data ?? {}) as NodeRuntimeState
|
||||
|
||||
export const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
|
||||
(edge?.data ?? {}) as EdgeRuntimeState
|
||||
|
||||
function createRunNodes() {
|
||||
return [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
width: 200,
|
||||
height: 80,
|
||||
data: { _waitingRun: false },
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function createRunEdges() {
|
||||
return [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n0',
|
||||
target: 'n1',
|
||||
data: {},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export function createViewportNodes() {
|
||||
return [
|
||||
createNode({
|
||||
id: 'n0',
|
||||
width: 200,
|
||||
height: 80,
|
||||
data: { _runningStatus: NodeRunningStatus.Succeeded },
|
||||
}),
|
||||
createNode({
|
||||
id: 'n1',
|
||||
position: { x: 100, y: 50 },
|
||||
width: 200,
|
||||
height: 80,
|
||||
data: { _waitingRun: true },
|
||||
}),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
position: { x: 400, y: 50 },
|
||||
width: 200,
|
||||
height: 80,
|
||||
parentId: 'n1',
|
||||
data: { _waitingRun: true },
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function createViewportEdges() {
|
||||
return [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n0',
|
||||
target: 'n1',
|
||||
sourceHandle: 'source',
|
||||
data: {},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export const containerParams = { clientWidth: 1200, clientHeight: 800 }
|
||||
|
||||
export function renderRunEventHook<T extends Record<string, unknown>>(
|
||||
useHook: () => T,
|
||||
options?: {
|
||||
nodes?: ReturnType<typeof createRunNodes>
|
||||
edges?: ReturnType<typeof createRunEdges>
|
||||
initialStoreState?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
const { nodes = createRunNodes(), edges = createRunEdges(), initialStoreState } = options ?? {}
|
||||
|
||||
return renderWorkflowFlowHook(() => ({
|
||||
...useHook(),
|
||||
nodes: useNodes(),
|
||||
edges: useEdges(),
|
||||
}), {
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps: { fitView: false },
|
||||
initialStoreState,
|
||||
})
|
||||
}
|
||||
|
||||
export function renderViewportHook<T extends Record<string, unknown>>(
|
||||
useHook: () => T,
|
||||
options?: {
|
||||
nodes?: ReturnType<typeof createViewportNodes>
|
||||
edges?: ReturnType<typeof createViewportEdges>
|
||||
initialStoreState?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
const {
|
||||
nodes = createViewportNodes(),
|
||||
edges = createViewportEdges(),
|
||||
initialStoreState,
|
||||
} = options ?? {}
|
||||
|
||||
return renderWorkflowFlowHook(() => ({
|
||||
...useHook(),
|
||||
nodes: useNodes(),
|
||||
edges: useEdges(),
|
||||
reactFlowStore: useStoreApi(),
|
||||
}), {
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps: { fitView: false },
|
||||
initialStoreState,
|
||||
})
|
||||
}
|
||||
|
||||
export const createStartedResponse = (overrides: Partial<WorkflowStartedResponse> = {}): WorkflowStartedResponse => ({
|
||||
task_id: 'task-2',
|
||||
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
|
||||
...overrides,
|
||||
} as WorkflowStartedResponse)
|
||||
|
||||
export const createNodeFinishedResponse = (overrides: Partial<NodeFinishedResponse> = {}): NodeFinishedResponse => ({
|
||||
data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
...overrides,
|
||||
} as NodeFinishedResponse)
|
||||
|
||||
export const createIterationNextResponse = (overrides: Partial<IterationNextResponse> = {}): IterationNextResponse => ({
|
||||
data: { node_id: 'n1' },
|
||||
...overrides,
|
||||
} as IterationNextResponse)
|
||||
|
||||
export const createIterationFinishedResponse = (overrides: Partial<IterationFinishedResponse> = {}): IterationFinishedResponse => ({
|
||||
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
...overrides,
|
||||
} as IterationFinishedResponse)
|
||||
|
||||
export const createLoopNextResponse = (overrides: Partial<LoopNextResponse> = {}): LoopNextResponse => ({
|
||||
data: { node_id: 'n1', index: 5 },
|
||||
...overrides,
|
||||
} as LoopNextResponse)
|
||||
|
||||
export const createLoopFinishedResponse = (overrides: Partial<LoopFinishedResponse> = {}): LoopFinishedResponse => ({
|
||||
data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
...overrides,
|
||||
} as LoopFinishedResponse)
|
||||
|
||||
export const createNodeStartedResponse = (overrides: Partial<NodeStartedResponse> = {}): NodeStartedResponse => ({
|
||||
data: { node_id: 'n1' },
|
||||
...overrides,
|
||||
} as NodeStartedResponse)
|
||||
|
||||
export const pausedRunningData = (): WorkflowRunningData['result'] => ({ status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'])
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { AgentLogResponse } from '@/types/workflow'
|
||||
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
|
||||
import { useWorkflowAgentLog } from '../use-workflow-agent-log'
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader/utils', () => ({
|
||||
getFilesInLogs: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
describe('useWorkflowAgentLog', () => {
|
||||
it('creates agent_log when execution_metadata has none', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1', execution_metadata: {} }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm1' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
const trace = store.getState().workflowRunningData!.tracing![0]
|
||||
expect(trace.execution_metadata!.agent_log).toHaveLength(1)
|
||||
expect(trace.execution_metadata!.agent_log![0].message_id).toBe('m1')
|
||||
})
|
||||
|
||||
it('appends to existing agent_log', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{
|
||||
node_id: 'n1',
|
||||
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] },
|
||||
}],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm2' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('updates an existing log entry by message_id', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{
|
||||
node_id: 'n1',
|
||||
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] },
|
||||
}],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm1', text: 'new' },
|
||||
} as unknown as AgentLogResponse)
|
||||
|
||||
const log = store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log!
|
||||
expect(log).toHaveLength(1)
|
||||
expect((log[0] as unknown as { text: string }).text).toBe('new')
|
||||
})
|
||||
|
||||
it('creates execution_metadata when it does not exist', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1' }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', message_id: 'm1' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
|
||||
import { WorkflowRunningStatus } from '../../../types'
|
||||
import { useWorkflowFailed } from '../use-workflow-failed'
|
||||
|
||||
describe('useWorkflowFailed', () => {
|
||||
it('sets status to Failed', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowFailed(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowFailed()
|
||||
|
||||
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Failed)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { WorkflowFinishedResponse } from '@/types/workflow'
|
||||
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
|
||||
import { useWorkflowFinished } from '../use-workflow-finished'
|
||||
|
||||
describe('useWorkflowFinished', () => {
|
||||
it('merges data into result and activates result tab for single string output', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowFinished({
|
||||
data: { status: 'succeeded', outputs: { answer: 'hello' } },
|
||||
} as WorkflowFinishedResponse)
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.result.status).toBe('succeeded')
|
||||
expect(state.resultTabActive).toBe(true)
|
||||
expect(state.resultText).toBe('hello')
|
||||
})
|
||||
|
||||
it('does not activate the result tab for multi-key outputs', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowFinished({
|
||||
data: { status: 'succeeded', outputs: { a: 'hello', b: 'world' } },
|
||||
} as WorkflowFinishedResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.resultTabActive).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { createNode } from '../../../__tests__/fixtures'
|
||||
import { baseRunningData } from '../../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, NodeRunningStatus } from '../../../types'
|
||||
import { useWorkflowNodeFinished } from '../use-workflow-node-finished'
|
||||
import {
|
||||
createNodeFinishedResponse,
|
||||
getEdgeRuntimeState,
|
||||
getNodeRuntimeState,
|
||||
renderRunEventHook,
|
||||
} from './test-helpers'
|
||||
|
||||
describe('useWorkflowNodeFinished', () => {
|
||||
it('updates tracing and node running status', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeFinished(createNodeFinishedResponse())
|
||||
})
|
||||
|
||||
const trace = store.getState().workflowRunningData!.tracing![0]
|
||||
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
})
|
||||
})
|
||||
|
||||
it('sets _runningBranchId for IfElse nodes', async () => {
|
||||
const { result } = renderRunEventHook(() => useWorkflowNodeFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeFinished(createNodeFinishedResponse({
|
||||
data: {
|
||||
id: 'trace-1',
|
||||
node_id: 'n1',
|
||||
node_type: BlockEnum.IfElse,
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
outputs: { selected_case_id: 'branch-a' },
|
||||
} as never,
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBe('branch-a')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { HumanInputFormFilledResponse } from '@/types/workflow'
|
||||
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
|
||||
import { useWorkflowNodeHumanInputFormFilled } from '../use-workflow-node-human-input-form-filled'
|
||||
|
||||
describe('useWorkflowNodeHumanInputFormFilled', () => {
|
||||
it('removes the form from pending and adds it to filled', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
humanInputFormDataList: [
|
||||
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeHumanInputFormFilled({
|
||||
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
|
||||
} as HumanInputFormFilledResponse)
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.humanInputFormDataList).toHaveLength(0)
|
||||
expect(state.humanInputFilledFormDataList).toHaveLength(1)
|
||||
expect(state.humanInputFilledFormDataList![0].node_id).toBe('n1')
|
||||
})
|
||||
|
||||
it('creates humanInputFilledFormDataList when it does not exist', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
humanInputFormDataList: [
|
||||
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeHumanInputFormFilled({
|
||||
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
|
||||
} as HumanInputFormFilledResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.humanInputFilledFormDataList).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { HumanInputFormTimeoutResponse } from '@/types/workflow'
|
||||
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
|
||||
import { useWorkflowNodeHumanInputFormTimeout } from '../use-workflow-node-human-input-form-timeout'
|
||||
|
||||
describe('useWorkflowNodeHumanInputFormTimeout', () => {
|
||||
it('sets expiration_time on the matching form', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormTimeout(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
humanInputFormDataList: [
|
||||
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '', expiration_time: 0 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowNodeHumanInputFormTimeout({
|
||||
data: { node_id: 'n1', node_title: 'Node 1', expiration_time: 1000 },
|
||||
} as HumanInputFormTimeoutResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.humanInputFormDataList![0].expiration_time).toBe(1000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { HumanInputRequiredResponse } from '@/types/workflow'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { createNode } from '../../../__tests__/fixtures'
|
||||
import { baseRunningData } from '../../../__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus } from '../../../types'
|
||||
import { useWorkflowNodeHumanInputRequired } from '../use-workflow-node-human-input-required'
|
||||
import {
|
||||
getNodeRuntimeState,
|
||||
renderViewportHook,
|
||||
} from './test-helpers'
|
||||
|
||||
describe('useWorkflowNodeHumanInputRequired', () => {
|
||||
it('creates humanInputFormDataList and sets tracing and node to Paused', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
|
||||
} as HumanInputRequiredResponse)
|
||||
})
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.humanInputFormDataList).toHaveLength(1)
|
||||
expect(state.humanInputFormDataList![0].form_id).toBe('f1')
|
||||
expect(state.tracing![0].status).toBe(NodeRunningStatus.Paused)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n1'))._runningStatus).toBe(NodeRunningStatus.Paused)
|
||||
})
|
||||
})
|
||||
|
||||
it('updates existing form entry for the same node_id', () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
humanInputFormDataList: [
|
||||
{ node_id: 'n1', form_id: 'old', node_title: 'Node 1', form_content: 'old' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
|
||||
} as HumanInputRequiredResponse)
|
||||
})
|
||||
|
||||
const formList = store.getState().workflowRunningData!.humanInputFormDataList!
|
||||
expect(formList).toHaveLength(1)
|
||||
expect(formList[0].form_id).toBe('new')
|
||||
})
|
||||
|
||||
it('appends a new form entry for a different node_id', () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ node_id: 'n2', status: NodeRunningStatus.Running }],
|
||||
humanInputFormDataList: [
|
||||
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeHumanInputRequired({
|
||||
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
|
||||
} as HumanInputRequiredResponse)
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { createNode } from '../../../__tests__/fixtures'
|
||||
import { baseRunningData } from '../../../__tests__/workflow-test-env'
|
||||
import { DEFAULT_ITER_TIMES } from '../../../constants'
|
||||
import { NodeRunningStatus } from '../../../types'
|
||||
import { useWorkflowNodeIterationFinished } from '../use-workflow-node-iteration-finished'
|
||||
import {
|
||||
createIterationFinishedResponse,
|
||||
getEdgeRuntimeState,
|
||||
getNodeRuntimeState,
|
||||
renderRunEventHook,
|
||||
} from './test-helpers'
|
||||
|
||||
describe('useWorkflowNodeIterationFinished', () => {
|
||||
it('updates tracing, resets iterTimes, updates node status and edges', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
}),
|
||||
iterTimes: 10,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeIterationFinished(createIterationFinishedResponse())
|
||||
})
|
||||
|
||||
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { baseRunningData } from '../../../__tests__/workflow-test-env'
|
||||
import { useWorkflowNodeIterationNext } from '../use-workflow-node-iteration-next'
|
||||
import {
|
||||
createIterationNextResponse,
|
||||
getNodeRuntimeState,
|
||||
renderRunEventHook,
|
||||
} from './test-helpers'
|
||||
|
||||
describe('useWorkflowNodeIterationNext', () => {
|
||||
it('sets _iterationIndex and increments iterTimes', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationNext(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData(),
|
||||
iterTimes: 3,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeIterationNext(createIterationNextResponse())
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._iterationIndex).toBe(3)
|
||||
})
|
||||
expect(store.getState().iterTimes).toBe(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IterationStartedResponse } from '@/types/workflow'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { baseRunningData } from '../../../__tests__/workflow-test-env'
|
||||
import { DEFAULT_ITER_TIMES } from '../../../constants'
|
||||
import { NodeRunningStatus } from '../../../types'
|
||||
import { useWorkflowNodeIterationStarted } from '../use-workflow-node-iteration-started'
|
||||
import {
|
||||
containerParams,
|
||||
createViewportNodes,
|
||||
getEdgeRuntimeState,
|
||||
getNodeRuntimeState,
|
||||
renderViewportHook,
|
||||
} from './test-helpers'
|
||||
|
||||
describe('useWorkflowNodeIterationStarted', () => {
|
||||
it('pushes to tracing, resets iterTimes, sets viewport, and updates node with _iterationLength', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeIterationStarted(), {
|
||||
nodes: createViewportNodes().slice(0, 2),
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData(),
|
||||
iterTimes: 99,
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeIterationStarted(
|
||||
{ data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
|
||||
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
|
||||
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(200)
|
||||
expect(transform[1]).toBe(310)
|
||||
expect(transform[2]).toBe(1)
|
||||
|
||||
const node = result.current.nodes.find(item => item.id === 'n1')
|
||||
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(node)._iterationLength).toBe(10)
|
||||
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { createNode } from '../../../__tests__/fixtures'
|
||||
import { baseRunningData } from '../../../__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus } from '../../../types'
|
||||
import { useWorkflowNodeLoopFinished } from '../use-workflow-node-loop-finished'
|
||||
import {
|
||||
createLoopFinishedResponse,
|
||||
getEdgeRuntimeState,
|
||||
getNodeRuntimeState,
|
||||
renderRunEventHook,
|
||||
} from './test-helpers'
|
||||
|
||||
describe('useWorkflowNodeLoopFinished', () => {
|
||||
it('updates tracing, node status and edges', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeLoopFinished(), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
data: { _runningStatus: NodeRunningStatus.Running },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Running }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeLoopFinished(createLoopFinishedResponse())
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Succeeded)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { createNode } from '../../../__tests__/fixtures'
|
||||
import { baseRunningData } from '../../../__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus } from '../../../types'
|
||||
import { useWorkflowNodeLoopNext } from '../use-workflow-node-loop-next'
|
||||
import {
|
||||
createLoopNextResponse,
|
||||
getNodeRuntimeState,
|
||||
renderRunEventHook,
|
||||
} from './test-helpers'
|
||||
|
||||
describe('useWorkflowNodeLoopNext', () => {
|
||||
it('sets _loopIndex and resets child nodes to waiting', async () => {
|
||||
const { result } = renderRunEventHook(() => useWorkflowNodeLoopNext(), {
|
||||
nodes: [
|
||||
createNode({ id: 'n1', data: {} }),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
position: { x: 300, y: 0 },
|
||||
parentId: 'n1',
|
||||
data: { _waitingRun: false },
|
||||
}),
|
||||
],
|
||||
edges: [],
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeLoopNext(createLoopNextResponse())
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._loopIndex).toBe(5)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._waitingRun).toBe(true)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Waiting)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { LoopStartedResponse } from '@/types/workflow'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { baseRunningData } from '../../../__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus } from '../../../types'
|
||||
import { useWorkflowNodeLoopStarted } from '../use-workflow-node-loop-started'
|
||||
import {
|
||||
containerParams,
|
||||
createViewportNodes,
|
||||
getEdgeRuntimeState,
|
||||
getNodeRuntimeState,
|
||||
renderViewportHook,
|
||||
} from './test-helpers'
|
||||
|
||||
describe('useWorkflowNodeLoopStarted', () => {
|
||||
it('pushes to tracing, sets viewport, and updates node with _loopLength', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeLoopStarted(), {
|
||||
nodes: createViewportNodes().slice(0, 2),
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeLoopStarted(
|
||||
{ data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Running)
|
||||
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(200)
|
||||
expect(transform[1]).toBe(310)
|
||||
expect(transform[2]).toBe(1)
|
||||
|
||||
const node = result.current.nodes.find(item => item.id === 'n1')
|
||||
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(node)._loopLength).toBe(5)
|
||||
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { baseRunningData } from '../../../__tests__/workflow-test-env'
|
||||
import { useWorkflowNodeRetry } from '../use-workflow-node-retry'
|
||||
import {
|
||||
getNodeRuntimeState,
|
||||
renderRunEventHook,
|
||||
} from './test-helpers'
|
||||
|
||||
describe('useWorkflowNodeRetry', () => {
|
||||
it('pushes retry data to tracing and updates _retryIndex', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowNodeRetry(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeRetry({
|
||||
data: { node_id: 'n1', retry_index: 2 },
|
||||
} as never)
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing).toHaveLength(1)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._retryIndex).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { baseRunningData } from '../../../__tests__/workflow-test-env'
|
||||
import { NodeRunningStatus } from '../../../types'
|
||||
import { useWorkflowNodeStarted } from '../use-workflow-node-started'
|
||||
import {
|
||||
containerParams,
|
||||
createNodeStartedResponse,
|
||||
getEdgeRuntimeState,
|
||||
getNodeRuntimeState,
|
||||
renderViewportHook,
|
||||
} from './test-helpers'
|
||||
|
||||
describe('useWorkflowNodeStarted', () => {
|
||||
it('pushes to tracing, sets node running, and adjusts viewport for root node', async () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(createNodeStartedResponse(), containerParams)
|
||||
})
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing).toHaveLength(1)
|
||||
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
|
||||
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(200)
|
||||
expect(transform[1]).toBe(310)
|
||||
expect(transform[2]).toBe(1)
|
||||
|
||||
const node = result.current.nodes.find(item => item.id === 'n1')
|
||||
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not adjust viewport for child nodes', async () => {
|
||||
const { result } = renderViewportHook(() => useWorkflowNodeStarted(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(createNodeStartedResponse({
|
||||
data: { node_id: 'n2' } as never,
|
||||
}), containerParams)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const transform = result.current.reactFlowStore.getState().transform
|
||||
expect(transform[0]).toBe(0)
|
||||
expect(transform[1]).toBe(0)
|
||||
expect(transform[2]).toBe(1)
|
||||
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
|
||||
it('updates existing tracing entry when node_id already exists', () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [
|
||||
{ node_id: 'n0', status: NodeRunningStatus.Succeeded } as never,
|
||||
{ node_id: 'n1', status: NodeRunningStatus.Succeeded } as never,
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(createNodeStartedResponse(), containerParams)
|
||||
})
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing).toHaveLength(2)
|
||||
expect(tracing[1].status).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
|
||||
import { WorkflowRunningStatus } from '../../../types'
|
||||
import { useWorkflowPaused } from '../use-workflow-paused'
|
||||
|
||||
describe('useWorkflowPaused', () => {
|
||||
it('sets status to Paused', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowPaused(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
result.current.handleWorkflowPaused()
|
||||
|
||||
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Paused)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useWorkflowRunEvent } from '../use-workflow-run-event'
|
||||
|
||||
const handlers = vi.hoisted(() => ({
|
||||
handleWorkflowStarted: vi.fn(),
|
||||
handleWorkflowFinished: vi.fn(),
|
||||
handleWorkflowFailed: vi.fn(),
|
||||
handleWorkflowNodeStarted: vi.fn(),
|
||||
handleWorkflowNodeFinished: vi.fn(),
|
||||
handleWorkflowNodeIterationStarted: vi.fn(),
|
||||
handleWorkflowNodeIterationNext: vi.fn(),
|
||||
handleWorkflowNodeIterationFinished: vi.fn(),
|
||||
handleWorkflowNodeLoopStarted: vi.fn(),
|
||||
handleWorkflowNodeLoopNext: vi.fn(),
|
||||
handleWorkflowNodeLoopFinished: vi.fn(),
|
||||
handleWorkflowNodeRetry: vi.fn(),
|
||||
handleWorkflowTextChunk: vi.fn(),
|
||||
handleWorkflowTextReplace: vi.fn(),
|
||||
handleWorkflowAgentLog: vi.fn(),
|
||||
handleWorkflowPaused: vi.fn(),
|
||||
handleWorkflowNodeHumanInputRequired: vi.fn(),
|
||||
handleWorkflowNodeHumanInputFormFilled: vi.fn(),
|
||||
handleWorkflowNodeHumanInputFormTimeout: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('..', () => ({
|
||||
useWorkflowStarted: () => ({ handleWorkflowStarted: handlers.handleWorkflowStarted }),
|
||||
useWorkflowFinished: () => ({ handleWorkflowFinished: handlers.handleWorkflowFinished }),
|
||||
useWorkflowFailed: () => ({ handleWorkflowFailed: handlers.handleWorkflowFailed }),
|
||||
useWorkflowNodeStarted: () => ({ handleWorkflowNodeStarted: handlers.handleWorkflowNodeStarted }),
|
||||
useWorkflowNodeFinished: () => ({ handleWorkflowNodeFinished: handlers.handleWorkflowNodeFinished }),
|
||||
useWorkflowNodeIterationStarted: () => ({ handleWorkflowNodeIterationStarted: handlers.handleWorkflowNodeIterationStarted }),
|
||||
useWorkflowNodeIterationNext: () => ({ handleWorkflowNodeIterationNext: handlers.handleWorkflowNodeIterationNext }),
|
||||
useWorkflowNodeIterationFinished: () => ({ handleWorkflowNodeIterationFinished: handlers.handleWorkflowNodeIterationFinished }),
|
||||
useWorkflowNodeLoopStarted: () => ({ handleWorkflowNodeLoopStarted: handlers.handleWorkflowNodeLoopStarted }),
|
||||
useWorkflowNodeLoopNext: () => ({ handleWorkflowNodeLoopNext: handlers.handleWorkflowNodeLoopNext }),
|
||||
useWorkflowNodeLoopFinished: () => ({ handleWorkflowNodeLoopFinished: handlers.handleWorkflowNodeLoopFinished }),
|
||||
useWorkflowNodeRetry: () => ({ handleWorkflowNodeRetry: handlers.handleWorkflowNodeRetry }),
|
||||
useWorkflowTextChunk: () => ({ handleWorkflowTextChunk: handlers.handleWorkflowTextChunk }),
|
||||
useWorkflowTextReplace: () => ({ handleWorkflowTextReplace: handlers.handleWorkflowTextReplace }),
|
||||
useWorkflowAgentLog: () => ({ handleWorkflowAgentLog: handlers.handleWorkflowAgentLog }),
|
||||
useWorkflowPaused: () => ({ handleWorkflowPaused: handlers.handleWorkflowPaused }),
|
||||
useWorkflowNodeHumanInputRequired: () => ({ handleWorkflowNodeHumanInputRequired: handlers.handleWorkflowNodeHumanInputRequired }),
|
||||
useWorkflowNodeHumanInputFormFilled: () => ({ handleWorkflowNodeHumanInputFormFilled: handlers.handleWorkflowNodeHumanInputFormFilled }),
|
||||
useWorkflowNodeHumanInputFormTimeout: () => ({ handleWorkflowNodeHumanInputFormTimeout: handlers.handleWorkflowNodeHumanInputFormTimeout }),
|
||||
}))
|
||||
|
||||
describe('useWorkflowRunEvent', () => {
|
||||
it('returns the composed handlers from all workflow event hooks', () => {
|
||||
const { result } = renderHook(() => useWorkflowRunEvent())
|
||||
|
||||
expect(result.current).toEqual(handlers)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { baseRunningData } from '../../../__tests__/workflow-test-env'
|
||||
import { WorkflowRunningStatus } from '../../../types'
|
||||
import { useWorkflowStarted } from '../use-workflow-started'
|
||||
import {
|
||||
createStartedResponse,
|
||||
getEdgeRuntimeState,
|
||||
getNodeRuntimeState,
|
||||
pausedRunningData,
|
||||
renderRunEventHook,
|
||||
} from './test-helpers'
|
||||
|
||||
describe('useWorkflowStarted', () => {
|
||||
it('initializes workflow running data and resets nodes and edges', async () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
|
||||
initialStoreState: { workflowRunningData: baseRunningData() },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStarted(createStartedResponse())
|
||||
})
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.task_id).toBe('task-2')
|
||||
expect(state.result.status).toBe(WorkflowRunningStatus.Running)
|
||||
expect(state.resultText).toBe('')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(true)
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBeUndefined()
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._sourceRunningStatus).toBeUndefined()
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBeUndefined()
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes from Paused without resetting nodes or edges', () => {
|
||||
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
result: pausedRunningData(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStarted(createStartedResponse({
|
||||
data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
|
||||
}))
|
||||
})
|
||||
|
||||
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running)
|
||||
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(false)
|
||||
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TextChunkResponse } from '@/types/workflow'
|
||||
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
|
||||
import { useWorkflowTextChunk } from '../use-workflow-text-chunk'
|
||||
|
||||
describe('useWorkflowTextChunk', () => {
|
||||
it('appends text and activates the result tab', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowTextChunk(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({ resultText: 'Hello' }),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowTextChunk({ data: { text: ' World' } } as TextChunkResponse)
|
||||
|
||||
const state = store.getState().workflowRunningData!
|
||||
expect(state.resultText).toBe('Hello World')
|
||||
expect(state.resultTabActive).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { TextReplaceResponse } from '@/types/workflow'
|
||||
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
|
||||
import { useWorkflowTextReplace } from '../use-workflow-text-replace'
|
||||
|
||||
describe('useWorkflowTextReplace', () => {
|
||||
it('replaces resultText', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowTextReplace(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({ resultText: 'old text' }),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowTextReplace({ data: { text: 'new text' } } as TextReplaceResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.resultText).toBe('new text')
|
||||
})
|
||||
})
|
||||
249
web/app/components/workflow/nodes/agent/__tests__/node.spec.tsx
Normal file
249
web/app/components/workflow/nodes/agent/__tests__/node.spec.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentNodeType } from '../types'
|
||||
import type useConfig from '../use-config'
|
||||
import type { StrategyParamItem } from '@/app/components/plugins/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { VarType } from '../../tool/types'
|
||||
import Node from '../node'
|
||||
|
||||
const mockUseConfig = vi.hoisted(() => vi.fn())
|
||||
const mockModelBar = vi.hoisted(() => vi.fn())
|
||||
const mockToolIcon = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (value: string | { en_US?: string }) => typeof value === 'string' ? value : value.en_US || '',
|
||||
}))
|
||||
|
||||
vi.mock('../components/model-bar', () => ({
|
||||
ModelBar: (props: { provider?: string, model?: string, param: string }) => {
|
||||
mockModelBar(props)
|
||||
return <div>{props.provider ? `${props.param}:${props.provider}/${props.model}` : `${props.param}:empty-model`}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/tool-icon', () => ({
|
||||
ToolIcon: (props: { providerName: string }) => {
|
||||
mockToolIcon(props)
|
||||
return <div>{`tool:${props.providerName}`}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/components/group', () => ({
|
||||
Group: ({ label, children }: { label: ReactNode, children: ReactNode }) => (
|
||||
<div>
|
||||
<div>{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
GroupLabel: ({ className, children }: { className?: string, children: ReactNode }) => <div className={className}>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/components/setting-item', () => ({
|
||||
SettingItem: ({
|
||||
label,
|
||||
status,
|
||||
tooltip,
|
||||
children,
|
||||
}: {
|
||||
label: ReactNode
|
||||
status?: string
|
||||
tooltip?: string
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
{`${label}:${status || 'normal'}:${tooltip || ''}`}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createStrategyParam = (overrides: Partial<StrategyParamItem> = {}): StrategyParamItem => ({
|
||||
name: 'requiredModel',
|
||||
type: FormTypeEnum.modelSelector,
|
||||
required: true,
|
||||
label: { en_US: 'Required Model' } as StrategyParamItem['label'],
|
||||
help: { en_US: 'Required model help' } as StrategyParamItem['help'],
|
||||
placeholder: { en_US: 'Required model placeholder' } as StrategyParamItem['placeholder'],
|
||||
scope: 'global',
|
||||
default: null,
|
||||
options: [],
|
||||
template: { enabled: false },
|
||||
auto_generate: { type: 'none' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createData = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
type: BlockEnum.Agent,
|
||||
output_schema: {},
|
||||
agent_strategy_provider_name: 'provider/agent',
|
||||
agent_strategy_name: 'react',
|
||||
agent_strategy_label: 'React Agent',
|
||||
plugin_unique_identifier: 'provider/agent:1.0.0',
|
||||
agent_parameters: {
|
||||
optionalModel: {
|
||||
type: VarType.constant,
|
||||
value: { provider: 'openai', model: 'gpt-4o' },
|
||||
},
|
||||
toolParam: {
|
||||
type: VarType.constant,
|
||||
value: { provider_name: 'author/tool-a' },
|
||||
},
|
||||
multiToolParam: {
|
||||
type: VarType.constant,
|
||||
value: [
|
||||
{ provider_name: 'author/tool-b' },
|
||||
{ provider_name: 'author/tool-c' },
|
||||
],
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
|
||||
readOnly: false,
|
||||
inputs: createData(),
|
||||
setInputs: vi.fn(),
|
||||
handleVarListChange: vi.fn(),
|
||||
handleAddVariable: vi.fn(),
|
||||
currentStrategy: {
|
||||
identity: {
|
||||
author: 'provider',
|
||||
name: 'react',
|
||||
icon: 'icon',
|
||||
label: { en_US: 'React Agent' } as StrategyParamItem['label'],
|
||||
provider: 'provider/agent',
|
||||
},
|
||||
parameters: [
|
||||
createStrategyParam(),
|
||||
createStrategyParam({
|
||||
name: 'optionalModel',
|
||||
required: false,
|
||||
}),
|
||||
createStrategyParam({
|
||||
name: 'toolParam',
|
||||
type: FormTypeEnum.toolSelector,
|
||||
required: false,
|
||||
}),
|
||||
createStrategyParam({
|
||||
name: 'multiToolParam',
|
||||
type: FormTypeEnum.multiToolSelector,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
description: { en_US: 'agent description' } as StrategyParamItem['label'],
|
||||
output_schema: {},
|
||||
features: [],
|
||||
},
|
||||
formData: {},
|
||||
onFormChange: vi.fn(),
|
||||
currentStrategyStatus: {
|
||||
plugin: { source: 'marketplace', installed: true },
|
||||
isExistInPlugin: false,
|
||||
},
|
||||
strategyProvider: undefined,
|
||||
pluginDetail: ({
|
||||
declaration: {
|
||||
label: { en_US: 'Plugin Marketplace' } as never,
|
||||
},
|
||||
} as never),
|
||||
availableVars: [],
|
||||
availableNodesWithParent: [],
|
||||
outputSchema: [],
|
||||
handleMemoryChange: vi.fn(),
|
||||
isChatMode: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('agent/node', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseConfig.mockReturnValue(createConfigResult())
|
||||
})
|
||||
|
||||
it('renders the not-set state when no strategy is configured', () => {
|
||||
mockUseConfig.mockReturnValue(createConfigResult({
|
||||
inputs: createData({
|
||||
agent_strategy_name: undefined,
|
||||
agent_strategy_label: undefined,
|
||||
agent_parameters: {},
|
||||
}),
|
||||
currentStrategy: undefined,
|
||||
}))
|
||||
|
||||
render(
|
||||
<Node
|
||||
id="agent-node"
|
||||
data={createData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.agent.strategyNotSet:normal:')).toBeInTheDocument()
|
||||
expect(mockModelBar).not.toHaveBeenCalled()
|
||||
expect(mockToolIcon).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders strategy status, required and selected model bars, and tool icons', () => {
|
||||
render(
|
||||
<Node
|
||||
id="agent-node"
|
||||
data={createData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/workflow.nodes.agent.strategy.shortLabel:error:/)).toHaveTextContent('React Agent')
|
||||
expect(screen.getByText(/workflow.nodes.agent.strategy.shortLabel:error:/)).toHaveTextContent('Plugin Marketplace')
|
||||
expect(screen.getByText('requiredModel:empty-model')).toBeInTheDocument()
|
||||
expect(screen.getByText('optionalModel:openai/gpt-4o')).toBeInTheDocument()
|
||||
expect(screen.getByText('tool:author/tool-a')).toBeInTheDocument()
|
||||
expect(screen.getByText('tool:author/tool-b')).toBeInTheDocument()
|
||||
expect(screen.getByText('tool:author/tool-c')).toBeInTheDocument()
|
||||
expect(mockModelBar).toHaveBeenCalledTimes(2)
|
||||
expect(mockToolIcon).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('skips optional models and empty tool values when no configuration is provided', () => {
|
||||
mockUseConfig.mockReturnValue(createConfigResult({
|
||||
inputs: createData({
|
||||
agent_parameters: {},
|
||||
}),
|
||||
currentStrategy: {
|
||||
...createConfigResult().currentStrategy!,
|
||||
parameters: [
|
||||
createStrategyParam({
|
||||
name: 'optionalModel',
|
||||
required: false,
|
||||
}),
|
||||
createStrategyParam({
|
||||
name: 'toolParam',
|
||||
type: FormTypeEnum.toolSelector,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
currentStrategyStatus: {
|
||||
plugin: { source: 'marketplace', installed: true },
|
||||
isExistInPlugin: true,
|
||||
},
|
||||
}))
|
||||
|
||||
render(
|
||||
<Node
|
||||
id="agent-node"
|
||||
data={createData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockModelBar).not.toHaveBeenCalled()
|
||||
expect(mockToolIcon).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('optionalModel:empty-model')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
297
web/app/components/workflow/nodes/agent/__tests__/panel.spec.tsx
Normal file
297
web/app/components/workflow/nodes/agent/__tests__/panel.spec.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentNodeType } from '../types'
|
||||
import type useConfig from '../use-config'
|
||||
import type { StrategyParamItem } from '@/app/components/plugins/types'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
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 { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Panel from '../panel'
|
||||
import { AgentFeature } from '../types'
|
||||
|
||||
const mockUseConfig = vi.hoisted(() => vi.fn())
|
||||
const mockResetEditor = vi.hoisted(() => vi.fn())
|
||||
const mockAgentStrategy = vi.hoisted(() => vi.fn())
|
||||
const mockMemoryConfig = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../../store', () => ({
|
||||
useStore: (selector: (state: { setControlPromptEditorRerenderKey: typeof mockResetEditor }) => unknown) => selector({
|
||||
setControlPromptEditorRerenderKey: mockResetEditor,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/components/agent-strategy', () => ({
|
||||
AgentStrategy: (props: {
|
||||
strategy?: {
|
||||
agent_strategy_provider_name: string
|
||||
agent_strategy_name: string
|
||||
agent_strategy_label: string
|
||||
agent_output_schema: AgentNodeType['output_schema']
|
||||
plugin_unique_identifier: string
|
||||
meta?: AgentNodeType['meta']
|
||||
}
|
||||
formSchema: Array<{ variable: string, tooltip?: StrategyParamItem['help'] }>
|
||||
formValue: Record<string, unknown>
|
||||
onStrategyChange: (strategy: {
|
||||
agent_strategy_provider_name: string
|
||||
agent_strategy_name: string
|
||||
agent_strategy_label: string
|
||||
agent_output_schema: AgentNodeType['output_schema']
|
||||
plugin_unique_identifier: string
|
||||
meta?: AgentNodeType['meta']
|
||||
}) => void
|
||||
onFormValueChange: (value: Record<string, unknown>) => void
|
||||
}) => {
|
||||
mockAgentStrategy(props)
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onStrategyChange({
|
||||
agent_strategy_provider_name: 'provider/updated',
|
||||
agent_strategy_name: 'updated',
|
||||
agent_strategy_label: 'Updated Strategy',
|
||||
agent_output_schema: {
|
||||
properties: {
|
||||
structured: {
|
||||
type: 'string',
|
||||
description: 'structured output',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugin_unique_identifier: 'provider/updated:1.0.0',
|
||||
meta: {
|
||||
version: '2.0.0',
|
||||
} as AgentNodeType['meta'],
|
||||
})}
|
||||
>
|
||||
change-strategy
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onFormValueChange({ instruction: 'Use the tool' })}>
|
||||
change-form
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/components/memory-config', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
readonly?: boolean
|
||||
config: { data?: AgentNodeType['memory'] }
|
||||
onChange: (value?: AgentNodeType['memory']) => void
|
||||
}) => {
|
||||
mockMemoryConfig(props)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onChange({
|
||||
window: {
|
||||
enabled: true,
|
||||
size: 8,
|
||||
},
|
||||
query_prompt_template: 'history',
|
||||
} as AgentNodeType['memory'])}
|
||||
>
|
||||
change-memory
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/components/output-vars', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
VarItem: ({ name, type, description }: { name: string, type: string, description?: string }) => (
|
||||
<div>{`${name}:${type}:${description || ''}`}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createStrategyParam = (overrides: Partial<StrategyParamItem> = {}): StrategyParamItem => ({
|
||||
name: 'instruction',
|
||||
type: FormTypeEnum.any,
|
||||
required: true,
|
||||
label: { en_US: 'Instruction' } as StrategyParamItem['label'],
|
||||
help: { en_US: 'Instruction help' } as StrategyParamItem['help'],
|
||||
placeholder: { en_US: 'Instruction placeholder' } as StrategyParamItem['placeholder'],
|
||||
scope: 'global',
|
||||
default: null,
|
||||
options: [],
|
||||
template: { enabled: false },
|
||||
auto_generate: { type: 'none' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createData = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
type: BlockEnum.Agent,
|
||||
output_schema: {
|
||||
properties: {
|
||||
summary: {
|
||||
type: 'string',
|
||||
description: 'summary output',
|
||||
},
|
||||
},
|
||||
},
|
||||
agent_strategy_provider_name: 'provider/agent',
|
||||
agent_strategy_name: 'react',
|
||||
agent_strategy_label: 'React Agent',
|
||||
plugin_unique_identifier: 'provider/agent:1.0.0',
|
||||
meta: { version: '1.0.0' } as AgentNodeType['meta'],
|
||||
memory: {
|
||||
window: {
|
||||
enabled: false,
|
||||
size: 3,
|
||||
},
|
||||
query_prompt_template: '',
|
||||
} as AgentNodeType['memory'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
|
||||
readOnly: false,
|
||||
inputs: createData(),
|
||||
setInputs: vi.fn(),
|
||||
handleVarListChange: vi.fn(),
|
||||
handleAddVariable: vi.fn(),
|
||||
currentStrategy: {
|
||||
identity: {
|
||||
author: 'provider',
|
||||
name: 'react',
|
||||
icon: 'icon',
|
||||
label: { en_US: 'React Agent' } as StrategyParamItem['label'],
|
||||
provider: 'provider/agent',
|
||||
},
|
||||
parameters: [
|
||||
createStrategyParam(),
|
||||
createStrategyParam({
|
||||
name: 'modelParam',
|
||||
type: FormTypeEnum.modelSelector,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
description: { en_US: 'agent description' } as StrategyParamItem['label'],
|
||||
output_schema: {},
|
||||
features: [AgentFeature.HISTORY_MESSAGES],
|
||||
},
|
||||
formData: {
|
||||
instruction: 'Plan and answer',
|
||||
},
|
||||
onFormChange: vi.fn(),
|
||||
currentStrategyStatus: {
|
||||
plugin: { source: 'marketplace', installed: true },
|
||||
isExistInPlugin: true,
|
||||
},
|
||||
strategyProvider: undefined,
|
||||
pluginDetail: undefined,
|
||||
availableVars: [],
|
||||
availableNodesWithParent: [],
|
||||
outputSchema: [{
|
||||
name: 'summary',
|
||||
type: 'String',
|
||||
description: 'summary output',
|
||||
}],
|
||||
handleMemoryChange: vi.fn(),
|
||||
isChatMode: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const panelProps = {} as NodePanelProps<AgentNodeType>['panelProps']
|
||||
|
||||
describe('agent/panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseConfig.mockReturnValue(createConfigResult())
|
||||
})
|
||||
|
||||
it('renders strategy data, forwards strategy and form updates, and exposes output vars', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setInputs = vi.fn()
|
||||
const onFormChange = vi.fn()
|
||||
const handleMemoryChange = vi.fn()
|
||||
|
||||
mockUseConfig.mockReturnValue(createConfigResult({
|
||||
setInputs,
|
||||
onFormChange,
|
||||
handleMemoryChange,
|
||||
}))
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="agent-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('text:String:workflow.nodes.agent.outputVars.text')).toBeInTheDocument()
|
||||
expect(screen.getByText('usage:object:workflow.nodes.agent.outputVars.usage')).toBeInTheDocument()
|
||||
expect(screen.getByText('files:Array[File]:workflow.nodes.agent.outputVars.files.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('json:Array[Object]:workflow.nodes.agent.outputVars.json')).toBeInTheDocument()
|
||||
expect(screen.getByText('summary:String:summary output')).toBeInTheDocument()
|
||||
expect(mockAgentStrategy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
formSchema: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
variable: 'instruction',
|
||||
tooltip: { en_US: 'Instruction help' },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
variable: 'modelParam',
|
||||
}),
|
||||
]),
|
||||
formValue: {
|
||||
instruction: 'Plan and answer',
|
||||
},
|
||||
}))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'change-strategy' }))
|
||||
await user.click(screen.getByRole('button', { name: 'change-form' }))
|
||||
await user.click(screen.getByRole('button', { name: 'change-memory' }))
|
||||
|
||||
expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agent_strategy_provider_name: 'provider/updated',
|
||||
agent_strategy_name: 'updated',
|
||||
agent_strategy_label: 'Updated Strategy',
|
||||
plugin_unique_identifier: 'provider/updated:1.0.0',
|
||||
output_schema: expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
structured: expect.any(Object),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
expect(onFormChange).toHaveBeenCalledWith({ instruction: 'Use the tool' })
|
||||
expect(handleMemoryChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
query_prompt_template: 'history',
|
||||
}))
|
||||
expect(mockResetEditor).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides memory config when chat mode support is unavailable', () => {
|
||||
mockUseConfig.mockReturnValue(createConfigResult({
|
||||
isChatMode: false,
|
||||
currentStrategy: {
|
||||
...createConfigResult().currentStrategy!,
|
||||
features: [],
|
||||
},
|
||||
}))
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="agent-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'change-memory' })).not.toBeInTheDocument()
|
||||
expect(mockMemoryConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,422 @@
|
||||
import type { AgentNodeType } from '../types'
|
||||
import type { StrategyParamItem } from '@/app/components/plugins/types'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { BlockEnum, VarType as WorkflowVarType } from '@/app/components/workflow/types'
|
||||
import { VarType } from '../../tool/types'
|
||||
import useConfig, { useStrategyInfo } from '../use-config'
|
||||
|
||||
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
|
||||
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
|
||||
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
|
||||
const mockUseVarList = vi.hoisted(() => vi.fn())
|
||||
const mockUseAvailableVarList = vi.hoisted(() => vi.fn())
|
||||
const mockUseStrategyProviderDetail = vi.hoisted(() => vi.fn())
|
||||
const mockUseFetchPluginsInMarketPlaceByIds = vi.hoisted(() => vi.fn())
|
||||
const mockUseCheckInstalled = vi.hoisted(() => vi.fn())
|
||||
const mockGenerateAgentToolValue = vi.hoisted(() => vi.fn())
|
||||
const mockToolParametersToFormSchemas = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: (...args: unknown[]) => mockUseNodesReadOnly(...args),
|
||||
useIsChatMode: (...args: unknown[]) => mockUseIsChatMode(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseNodeCrud(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-var-list', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseVarList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseAvailableVarList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-strategy', () => ({
|
||||
useStrategyProviderDetail: (...args: unknown[]) => mockUseStrategyProviderDetail(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useFetchPluginsInMarketPlaceByIds: (...args: unknown[]) => mockUseFetchPluginsInMarketPlaceByIds(...args),
|
||||
useCheckInstalled: (...args: unknown[]) => mockUseCheckInstalled(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
generateAgentToolValue: (...args: unknown[]) => mockGenerateAgentToolValue(...args),
|
||||
toolParametersToFormSchemas: (...args: unknown[]) => mockToolParametersToFormSchemas(...args),
|
||||
}))
|
||||
|
||||
const createStrategyParam = (overrides: Partial<StrategyParamItem> = {}): StrategyParamItem => ({
|
||||
name: 'instruction',
|
||||
type: FormTypeEnum.any,
|
||||
required: true,
|
||||
label: { en_US: 'Instruction' } as StrategyParamItem['label'],
|
||||
help: { en_US: 'Instruction help' } as StrategyParamItem['help'],
|
||||
placeholder: { en_US: 'Instruction placeholder' } as StrategyParamItem['placeholder'],
|
||||
scope: 'global',
|
||||
default: null,
|
||||
options: [],
|
||||
template: { enabled: false },
|
||||
auto_generate: { type: 'none' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createToolValue = () => ({
|
||||
settings: {
|
||||
api_key: 'secret',
|
||||
},
|
||||
parameters: {
|
||||
query: 'weather',
|
||||
},
|
||||
schemas: [
|
||||
{
|
||||
variable: 'api_key',
|
||||
form: 'form',
|
||||
},
|
||||
{
|
||||
variable: 'query',
|
||||
form: 'llm',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const createData = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
type: BlockEnum.Agent,
|
||||
output_schema: {
|
||||
properties: {
|
||||
summary: {
|
||||
type: 'string',
|
||||
description: 'summary output',
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'number',
|
||||
},
|
||||
description: 'items output',
|
||||
},
|
||||
},
|
||||
},
|
||||
agent_strategy_provider_name: 'provider/agent',
|
||||
agent_strategy_name: 'react',
|
||||
agent_strategy_label: 'React Agent',
|
||||
plugin_unique_identifier: 'provider/agent:1.0.0',
|
||||
agent_parameters: {
|
||||
instruction: {
|
||||
type: VarType.variable,
|
||||
value: '#start.topic#',
|
||||
},
|
||||
modelParam: {
|
||||
type: VarType.constant,
|
||||
value: {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
},
|
||||
},
|
||||
meta: { version: '1.0.0' } as AgentNodeType['meta'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('agent/use-config', () => {
|
||||
const providerRefetch = vi.fn()
|
||||
const marketplaceRefetch = vi.fn()
|
||||
const setInputs = vi.fn()
|
||||
const handleVarListChange = vi.fn()
|
||||
const handleAddVariable = vi.fn()
|
||||
let currentInputs: AgentNodeType
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentInputs = createData({
|
||||
tool_node_version: '2',
|
||||
})
|
||||
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
mockUseNodeCrud.mockImplementation(() => ({
|
||||
inputs: currentInputs,
|
||||
setInputs,
|
||||
}))
|
||||
mockUseVarList.mockReturnValue({
|
||||
handleVarListChange,
|
||||
handleAddVariable,
|
||||
} as never)
|
||||
mockUseAvailableVarList.mockReturnValue({
|
||||
availableVars: [{
|
||||
nodeId: 'node-1',
|
||||
title: 'Start',
|
||||
vars: [{
|
||||
variable: 'topic',
|
||||
type: WorkflowVarType.string,
|
||||
}],
|
||||
}],
|
||||
availableNodesWithParent: [{
|
||||
nodeId: 'node-1',
|
||||
title: 'Start',
|
||||
}],
|
||||
} as never)
|
||||
mockUseStrategyProviderDetail.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
declaration: {
|
||||
strategies: [{
|
||||
identity: {
|
||||
name: 'react',
|
||||
},
|
||||
parameters: [
|
||||
createStrategyParam(),
|
||||
createStrategyParam({
|
||||
name: 'modelParam',
|
||||
type: FormTypeEnum.modelSelector,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
refetch: providerRefetch,
|
||||
} as never)
|
||||
mockUseFetchPluginsInMarketPlaceByIds.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: {
|
||||
data: {
|
||||
plugins: [{ id: 'provider/agent' }],
|
||||
},
|
||||
},
|
||||
refetch: marketplaceRefetch,
|
||||
} as never)
|
||||
mockUseCheckInstalled.mockReturnValue({
|
||||
data: {
|
||||
plugins: [{
|
||||
declaration: {
|
||||
label: { en_US: 'Installed Agent Plugin' },
|
||||
},
|
||||
}],
|
||||
},
|
||||
} as never)
|
||||
mockToolParametersToFormSchemas.mockImplementation(value => value as never)
|
||||
mockGenerateAgentToolValue.mockImplementation((_value, schemas, isLLM) => ({
|
||||
kind: isLLM ? 'llm' : 'setting',
|
||||
fields: (schemas as Array<{ variable: string }>).map(item => item.variable),
|
||||
}) as never)
|
||||
})
|
||||
|
||||
it('returns an undefined strategy status while strategy data is still loading and can refetch dependencies', () => {
|
||||
mockUseStrategyProviderDetail.mockReturnValue({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
data: undefined,
|
||||
refetch: providerRefetch,
|
||||
} as never)
|
||||
|
||||
const { result } = renderHook(() => useStrategyInfo('provider/agent', 'react'))
|
||||
|
||||
expect(result.current.strategyStatus).toBeUndefined()
|
||||
expect(result.current.strategy).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
result.current.refetch()
|
||||
})
|
||||
|
||||
expect(providerRefetch).toHaveBeenCalledTimes(1)
|
||||
expect(marketplaceRefetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resolves strategy status for external plugins that are missing or not installed', () => {
|
||||
mockUseStrategyProviderDetail.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
data: {
|
||||
declaration: {
|
||||
strategies: [],
|
||||
},
|
||||
},
|
||||
refetch: providerRefetch,
|
||||
} as never)
|
||||
mockUseFetchPluginsInMarketPlaceByIds.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: {
|
||||
data: {
|
||||
plugins: [],
|
||||
},
|
||||
},
|
||||
refetch: marketplaceRefetch,
|
||||
} as never)
|
||||
|
||||
const { result } = renderHook(() => useStrategyInfo('provider/agent', 'react'))
|
||||
|
||||
expect(result.current.strategyStatus).toEqual({
|
||||
plugin: {
|
||||
source: 'external',
|
||||
installed: false,
|
||||
},
|
||||
isExistInPlugin: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes derived form data, strategy state, output schema, and setter helpers', () => {
|
||||
const { result } = renderHook(() => useConfig('agent-node', currentInputs))
|
||||
|
||||
expect(result.current.readOnly).toBe(false)
|
||||
expect(result.current.isChatMode).toBe(true)
|
||||
expect(result.current.formData).toEqual({
|
||||
instruction: '#start.topic#',
|
||||
modelParam: {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
})
|
||||
expect(result.current.currentStrategyStatus).toEqual({
|
||||
plugin: {
|
||||
source: 'marketplace',
|
||||
installed: true,
|
||||
},
|
||||
isExistInPlugin: true,
|
||||
})
|
||||
expect(result.current.availableVars).toHaveLength(1)
|
||||
expect(result.current.availableNodesWithParent).toEqual([{
|
||||
nodeId: 'node-1',
|
||||
title: 'Start',
|
||||
}])
|
||||
expect(result.current.outputSchema).toEqual([
|
||||
{ name: 'summary', type: 'String', description: 'summary output' },
|
||||
{ name: 'items', type: 'Array[Number]', description: 'items output' },
|
||||
])
|
||||
|
||||
setInputs.mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.onFormChange({
|
||||
instruction: '#start.updated#',
|
||||
modelParam: {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet',
|
||||
},
|
||||
})
|
||||
result.current.handleMemoryChange({
|
||||
window: {
|
||||
enabled: true,
|
||||
size: 6,
|
||||
},
|
||||
query_prompt_template: 'history',
|
||||
} as AgentNodeType['memory'])
|
||||
})
|
||||
|
||||
expect(setInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
agent_parameters: {
|
||||
instruction: {
|
||||
type: VarType.variable,
|
||||
value: '#start.updated#',
|
||||
},
|
||||
modelParam: {
|
||||
type: VarType.constant,
|
||||
value: {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
expect(setInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
memory: {
|
||||
window: {
|
||||
enabled: true,
|
||||
size: 6,
|
||||
},
|
||||
query_prompt_template: 'history',
|
||||
},
|
||||
}))
|
||||
expect(result.current.handleVarListChange).toBe(handleVarListChange)
|
||||
expect(result.current.handleAddVariable).toBe(handleAddVariable)
|
||||
expect(result.current.pluginDetail).toEqual({
|
||||
declaration: {
|
||||
label: { en_US: 'Installed Agent Plugin' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('formats legacy tool selector values before exposing the node config', async () => {
|
||||
currentInputs = createData({
|
||||
tool_node_version: undefined,
|
||||
agent_parameters: {
|
||||
toolParam: {
|
||||
type: VarType.constant,
|
||||
value: createToolValue(),
|
||||
},
|
||||
multiToolParam: {
|
||||
type: VarType.constant,
|
||||
value: [createToolValue()],
|
||||
},
|
||||
},
|
||||
})
|
||||
mockUseStrategyProviderDetail.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
declaration: {
|
||||
strategies: [{
|
||||
identity: {
|
||||
name: 'react',
|
||||
},
|
||||
parameters: [
|
||||
createStrategyParam({
|
||||
name: 'toolParam',
|
||||
type: FormTypeEnum.toolSelector,
|
||||
required: false,
|
||||
}),
|
||||
createStrategyParam({
|
||||
name: 'multiToolParam',
|
||||
type: FormTypeEnum.multiToolSelector,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
refetch: providerRefetch,
|
||||
} as never)
|
||||
|
||||
renderHook(() => useConfig('agent-node', currentInputs))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tool_node_version: '2',
|
||||
agent_parameters: expect.objectContaining({
|
||||
toolParam: expect.objectContaining({
|
||||
value: expect.objectContaining({
|
||||
settings: {
|
||||
kind: 'setting',
|
||||
fields: ['api_key'],
|
||||
},
|
||||
parameters: {
|
||||
kind: 'llm',
|
||||
fields: ['query'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
multiToolParam: expect.objectContaining({
|
||||
value: [expect.objectContaining({
|
||||
settings: {
|
||||
kind: 'setting',
|
||||
fields: ['api_key'],
|
||||
},
|
||||
parameters: {
|
||||
kind: 'llm',
|
||||
fields: ['query'],
|
||||
},
|
||||
})],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { AgentNodeType } from '../types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import formatTracing from '@/app/components/workflow/run/utils/format-log'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import useNodeCrud from '../../_base/hooks/use-node-crud'
|
||||
import { VarType } from '../../tool/types'
|
||||
import { useStrategyInfo } from '../use-config'
|
||||
import useSingleRunFormParams from '../use-single-run-form-params'
|
||||
|
||||
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../use-config', async () => {
|
||||
const actual = await vi.importActual<typeof import('../use-config')>('../use-config')
|
||||
return {
|
||||
...actual,
|
||||
useStrategyInfo: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockFormatTracing = vi.mocked(formatTracing)
|
||||
const mockUseNodeCrud = vi.mocked(useNodeCrud)
|
||||
const mockUseStrategyInfo = vi.mocked(useStrategyInfo)
|
||||
|
||||
const createData = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
type: BlockEnum.Agent,
|
||||
output_schema: {},
|
||||
agent_strategy_provider_name: 'provider/agent',
|
||||
agent_strategy_name: 'react',
|
||||
agent_strategy_label: 'React Agent',
|
||||
agent_parameters: {
|
||||
prompt: {
|
||||
type: VarType.variable,
|
||||
value: '#start.topic#',
|
||||
},
|
||||
summary: {
|
||||
type: VarType.variable,
|
||||
value: '#node-2.answer#',
|
||||
},
|
||||
count: {
|
||||
type: VarType.constant,
|
||||
value: 2,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('agent/use-single-run-form-params', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodeCrud.mockReturnValue({
|
||||
inputs: createData(),
|
||||
setInputs: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useNodeCrud>)
|
||||
mockUseStrategyInfo.mockReturnValue({
|
||||
strategyProvider: undefined,
|
||||
strategy: {
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string' },
|
||||
{ name: 'summary', type: 'string' },
|
||||
{ name: 'count', type: 'number' },
|
||||
],
|
||||
},
|
||||
strategyStatus: undefined,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useStrategyInfo>)
|
||||
mockFormatTracing.mockReturnValue([{
|
||||
id: 'agent-node',
|
||||
status: 'succeeded',
|
||||
}] as unknown as ReturnType<typeof formatTracing>)
|
||||
})
|
||||
|
||||
it('builds a single-run variable form, returns node info, and skips malformed dependent vars', () => {
|
||||
const setRunInputData = vi.fn()
|
||||
const getInputVars = vi.fn<() => InputVar[]>(() => [
|
||||
{
|
||||
label: 'Prompt',
|
||||
variable: '#start.topic#',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Broken',
|
||||
variable: undefined as unknown as string,
|
||||
type: InputVarType.textInput,
|
||||
required: false,
|
||||
},
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'agent-node',
|
||||
payload: createData(),
|
||||
runInputData: { topic: 'finance' },
|
||||
runInputDataRef: { current: { topic: 'finance' } },
|
||||
getInputVars,
|
||||
setRunInputData,
|
||||
toVarInputs: () => [],
|
||||
runResult: { id: 'trace-1' } as never,
|
||||
}))
|
||||
|
||||
expect(getInputVars).toHaveBeenCalledWith(['#start.topic#', '#node-2.answer#'])
|
||||
expect(result.current.forms).toHaveLength(1)
|
||||
expect(result.current.forms[0].inputs).toHaveLength(2)
|
||||
expect(result.current.forms[0].values).toEqual({ topic: 'finance' })
|
||||
expect(result.current.nodeInfo).toEqual({
|
||||
id: 'agent-node',
|
||||
status: 'succeeded',
|
||||
})
|
||||
|
||||
result.current.forms[0].onChange({ topic: 'updated' })
|
||||
|
||||
expect(setRunInputData).toHaveBeenCalledWith({ topic: 'updated' })
|
||||
expect(result.current.getDependentVars()).toEqual([
|
||||
['start', 'topic'],
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty form list when no variable input is required and no run result is available', () => {
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'agent-node',
|
||||
payload: createData(),
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
getInputVars: () => [],
|
||||
setRunInputData: vi.fn(),
|
||||
toVarInputs: () => [],
|
||||
runResult: undefined as never,
|
||||
}))
|
||||
|
||||
expect(result.current.forms).toEqual([])
|
||||
expect(result.current.nodeInfo).toBeUndefined()
|
||||
expect(result.current.getDependentVars()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ModelBar } from '../model-bar'
|
||||
|
||||
type ModelProviderItem = {
|
||||
provider: string
|
||||
models: Array<{ model: string }>
|
||||
}
|
||||
|
||||
const mockModelLists = new Map<ModelTypeEnum, ModelProviderItem[]>()
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: (modelType: ModelTypeEnum) => ({
|
||||
data: mockModelLists.get(modelType) || [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({
|
||||
defaultModel,
|
||||
modelList,
|
||||
}: {
|
||||
defaultModel?: { provider: string, model: string }
|
||||
modelList: ModelProviderItem[]
|
||||
}) => (
|
||||
<div>
|
||||
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
|
||||
:
|
||||
{modelList.length}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
default: ({ color }: { color: string }) => <div>{`indicator:${color}`}</div>,
|
||||
}))
|
||||
|
||||
describe('agent/model-bar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockModelLists.clear()
|
||||
mockModelLists.set('llm' as ModelTypeEnum, [{ provider: 'openai', models: [{ model: 'gpt-4o' }] }])
|
||||
mockModelLists.set('moderation' as ModelTypeEnum, [])
|
||||
mockModelLists.set('rerank' as ModelTypeEnum, [])
|
||||
mockModelLists.set('speech2text' as ModelTypeEnum, [])
|
||||
mockModelLists.set('text-embedding' as ModelTypeEnum, [])
|
||||
mockModelLists.set('tts' as ModelTypeEnum, [])
|
||||
})
|
||||
|
||||
it('should render an empty readonly selector with a warning when no model is selected', () => {
|
||||
render(<ModelBar />)
|
||||
|
||||
const emptySelector = screen.getByText((_, element) => element?.textContent === 'no-model:0')
|
||||
|
||||
fireEvent.mouseEnter(emptySelector)
|
||||
|
||||
expect(emptySelector).toBeInTheDocument()
|
||||
expect(screen.getByText('indicator:red')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.agent.modelNotSelected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the selected model without warning when it is installed', () => {
|
||||
render(<ModelBar provider="openai" model="gpt-4o" />)
|
||||
|
||||
expect(screen.getByText('openai/gpt-4o:1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('indicator:red')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show a warning tooltip when the selected model is not installed', () => {
|
||||
render(<ModelBar provider="openai" model="gpt-4.1" />)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('openai/gpt-4.1:1'))
|
||||
|
||||
expect(screen.getByText('openai/gpt-4.1:1')).toBeInTheDocument()
|
||||
expect(screen.getByText('indicator:red')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.agent.modelNotInstallTooltip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ToolIcon } from '../tool-icon'
|
||||
|
||||
type ToolProvider = {
|
||||
id?: string
|
||||
name?: string
|
||||
icon?: string | { content: string, background: string }
|
||||
is_team_authorization?: boolean
|
||||
}
|
||||
|
||||
let mockBuiltInTools: ToolProvider[] | undefined
|
||||
let mockCustomTools: ToolProvider[] | undefined
|
||||
let mockWorkflowTools: ToolProvider[] | undefined
|
||||
let mockMcpTools: ToolProvider[] | undefined
|
||||
let mockMarketplaceIcon: string | { content: string, background: string } | undefined
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => ({ data: mockBuiltInTools }),
|
||||
useAllCustomTools: () => ({ data: mockCustomTools }),
|
||||
useAllWorkflowTools: () => ({ data: mockWorkflowTools }),
|
||||
useAllMCPTools: () => ({ data: mockMcpTools }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({
|
||||
icon,
|
||||
background,
|
||||
className,
|
||||
}: {
|
||||
icon?: string
|
||||
background?: string
|
||||
className?: string
|
||||
}) => <div className={className}>{`app-icon:${background}:${icon}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/other', () => ({
|
||||
Group: ({ className }: { className?: string }) => <div className={className}>group-icon</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
default: ({ color }: { color: string }) => <div>{`indicator:${color}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/get-icon', () => ({
|
||||
getIconFromMarketPlace: () => mockMarketplaceIcon,
|
||||
}))
|
||||
|
||||
describe('agent/tool-icon', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockBuiltInTools = []
|
||||
mockCustomTools = []
|
||||
mockWorkflowTools = []
|
||||
mockMcpTools = []
|
||||
mockMarketplaceIcon = undefined
|
||||
})
|
||||
|
||||
it('should render a string icon, recover from fetch errors, and keep installed tools warning-free', () => {
|
||||
mockBuiltInTools = [{
|
||||
name: 'author/tool-a',
|
||||
icon: 'https://example.com/tool-a.png',
|
||||
is_team_authorization: true,
|
||||
}]
|
||||
|
||||
render(<ToolIcon id="tool-1" providerName="author/tool-a" />)
|
||||
|
||||
const icon = screen.getByRole('img', { name: 'tool icon' })
|
||||
expect(icon).toHaveAttribute('src', 'https://example.com/tool-a.png')
|
||||
expect(screen.queryByText(/indicator:/)).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseEnter(icon)
|
||||
expect(screen.queryByText('workflow.nodes.agent.toolNotInstallTooltip')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.error(icon)
|
||||
expect(screen.getByText('group-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render authorization and installation warnings with the correct icon sources', () => {
|
||||
mockWorkflowTools = [{
|
||||
id: 'author/tool-b',
|
||||
icon: {
|
||||
content: 'B',
|
||||
background: '#fff',
|
||||
},
|
||||
is_team_authorization: false,
|
||||
}]
|
||||
|
||||
const { rerender } = render(<ToolIcon id="tool-2" providerName="author/tool-b" />)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('app-icon:#fff:B'))
|
||||
expect(screen.getByText('indicator:yellow')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.agent.toolNotAuthorizedTooltip:{"tool":"tool-b"}')).toBeInTheDocument()
|
||||
|
||||
mockWorkflowTools = []
|
||||
mockMarketplaceIcon = 'https://example.com/market-tool.png'
|
||||
rerender(<ToolIcon id="tool-3" providerName="market/tool-c" />)
|
||||
|
||||
const marketplaceIcon = screen.getByRole('img', { name: 'tool icon' })
|
||||
fireEvent.mouseEnter(marketplaceIcon)
|
||||
expect(marketplaceIcon).toHaveAttribute('src', 'https://example.com/market-tool.png')
|
||||
expect(screen.getByText('indicator:red')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.agent.toolNotInstallTooltip:{"tool":"tool-c"}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to the group icon while tool data is still loading', () => {
|
||||
mockBuiltInTools = undefined
|
||||
|
||||
render(<ToolIcon id="tool-4" providerName="author/tool-d" />)
|
||||
|
||||
expect(screen.getByText('group-icon')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/indicator:/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { AnswerNodeType } from '../types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Panel from '../panel'
|
||||
|
||||
type MockEditorProps = {
|
||||
readOnly: boolean
|
||||
title: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
nodesOutputVars: unknown[]
|
||||
availableNodes: unknown[]
|
||||
}
|
||||
|
||||
const mockUseConfig = vi.hoisted(() => vi.fn())
|
||||
const mockUseAvailableVarList = vi.hoisted(() => vi.fn())
|
||||
const mockEditorRender = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseAvailableVarList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
|
||||
__esModule: true,
|
||||
default: (props: MockEditorProps) => {
|
||||
mockEditorRender(props)
|
||||
return (
|
||||
<button type="button" onClick={() => props.onChange('Updated answer')}>
|
||||
{props.title}
|
||||
:
|
||||
{props.value}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const createData = (overrides: Partial<AnswerNodeType> = {}): AnswerNodeType => ({
|
||||
title: 'Answer',
|
||||
desc: '',
|
||||
type: BlockEnum.Answer,
|
||||
variables: [],
|
||||
answer: 'Initial answer',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('AnswerPanel', () => {
|
||||
const handleAnswerChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseConfig.mockReturnValue({
|
||||
readOnly: false,
|
||||
inputs: createData(),
|
||||
handleAnswerChange,
|
||||
filterVar: vi.fn(),
|
||||
})
|
||||
mockUseAvailableVarList.mockReturnValue({
|
||||
availableVars: [{ variable: 'context', type: 'string' }],
|
||||
availableNodesWithParent: [{ value: 'node-1', label: 'Node 1' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass editor state and available variables through to the prompt editor', () => {
|
||||
render(<Panel id="answer-node" data={createData()} panelProps={{} as PanelProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'workflow.nodes.answer.answer:Initial answer' })).toBeInTheDocument()
|
||||
expect(mockEditorRender).toHaveBeenCalledWith(expect.objectContaining({
|
||||
readOnly: false,
|
||||
title: 'workflow.nodes.answer.answer',
|
||||
value: 'Initial answer',
|
||||
nodesOutputVars: [{ variable: 'context', type: 'string' }],
|
||||
availableNodes: [{ value: 'node-1', label: 'Node 1' }],
|
||||
isSupportFileVar: true,
|
||||
justVar: true,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should delegate answer edits to use-config', () => {
|
||||
render(<Panel id="answer-node" data={createData()} panelProps={{} as PanelProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.answer.answer:Initial answer' }))
|
||||
|
||||
expect(handleAnswerChange).toHaveBeenCalledWith('Updated answer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { AnswerNodeType } from '../types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
|
||||
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
|
||||
const mockUseVarList = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: () => mockUseNodesReadOnly(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseNodeCrud(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-var-list', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseVarList(...args),
|
||||
}))
|
||||
|
||||
const createPayload = (overrides: Partial<AnswerNodeType> = {}): AnswerNodeType => ({
|
||||
title: 'Answer',
|
||||
desc: '',
|
||||
type: BlockEnum.Answer,
|
||||
variables: [],
|
||||
answer: 'Initial answer',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('answer/use-config', () => {
|
||||
const mockSetInputs = vi.fn()
|
||||
const mockHandleVarListChange = vi.fn()
|
||||
const mockHandleAddVariable = vi.fn()
|
||||
let currentInputs: AnswerNodeType
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentInputs = createPayload()
|
||||
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
|
||||
mockUseNodeCrud.mockReturnValue({
|
||||
inputs: currentInputs,
|
||||
setInputs: mockSetInputs,
|
||||
})
|
||||
mockUseVarList.mockReturnValue({
|
||||
handleVarListChange: mockHandleVarListChange,
|
||||
handleAddVariable: mockHandleAddVariable,
|
||||
})
|
||||
})
|
||||
|
||||
it('should update the answer text and expose var-list handlers', () => {
|
||||
const { result } = renderHook(() => useConfig('answer-node', currentInputs))
|
||||
|
||||
act(() => {
|
||||
result.current.handleAnswerChange('Updated answer')
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
answer: 'Updated answer',
|
||||
}))
|
||||
expect(result.current.handleVarListChange).toBe(mockHandleVarListChange)
|
||||
expect(result.current.handleAddVariable).toBe(mockHandleAddVariable)
|
||||
expect(result.current.readOnly).toBe(false)
|
||||
})
|
||||
|
||||
it('should filter out array-object variables from the prompt editor picker', () => {
|
||||
const { result } = renderHook(() => useConfig('answer-node', currentInputs))
|
||||
|
||||
expect(result.current.filterVar({
|
||||
variable: 'items',
|
||||
type: VarType.arrayObject,
|
||||
})).toBe(false)
|
||||
expect(result.current.filterVar({
|
||||
variable: 'message',
|
||||
type: VarType.string,
|
||||
})).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { AssignerNodeOperation, AssignerNodeType } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
import { AssignerNodeInputType, WriteMode } from '../types'
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
|
||||
return {
|
||||
...actual,
|
||||
useNodes: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
|
||||
VariableLabelInNode: ({
|
||||
variables,
|
||||
nodeTitle,
|
||||
nodeType,
|
||||
rightSlot,
|
||||
}: {
|
||||
variables: string[]
|
||||
nodeTitle?: string
|
||||
nodeType?: BlockEnum
|
||||
rightSlot?: React.ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
<span>{`${nodeTitle}:${nodeType}:${variables.join('.')}`}</span>
|
||||
{rightSlot}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockUseNodes = vi.mocked(useNodes)
|
||||
|
||||
const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({
|
||||
variable_selector: ['node-1', 'count'],
|
||||
input_type: AssignerNodeInputType.variable,
|
||||
operation: WriteMode.overwrite,
|
||||
value: ['node-2', 'result'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createData = (overrides: Partial<AssignerNodeType> = {}): AssignerNodeType => ({
|
||||
title: 'Assigner',
|
||||
desc: '',
|
||||
type: BlockEnum.VariableAssigner,
|
||||
version: '2',
|
||||
items: [createOperation()],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('assigner/node', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodes.mockReturnValue([
|
||||
{
|
||||
id: 'node-1',
|
||||
data: {
|
||||
title: 'Answer',
|
||||
type: BlockEnum.Answer,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'start-node',
|
||||
data: {
|
||||
title: 'Start',
|
||||
type: BlockEnum.Start,
|
||||
},
|
||||
},
|
||||
] as ReturnType<typeof useNodes>)
|
||||
})
|
||||
|
||||
it('renders the empty-state hint when no assignable variable is configured', () => {
|
||||
render(
|
||||
<Node
|
||||
id="assigner-node"
|
||||
data={createData({
|
||||
items: [createOperation({ variable_selector: [] })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.assigner.varNotSet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders both version 2 and legacy previews with resolved node labels', () => {
|
||||
const { container, rerender } = render(
|
||||
<Node
|
||||
id="assigner-node"
|
||||
data={createData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Answer:answer:node-1.count')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.over-write')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Node
|
||||
id="assigner-node"
|
||||
data={{
|
||||
title: 'Legacy Assigner',
|
||||
desc: '',
|
||||
type: BlockEnum.VariableAssigner,
|
||||
assigned_variable_selector: ['sys', 'query'],
|
||||
write_mode: WriteMode.append,
|
||||
} as unknown as AssignerNodeType}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Start:start:sys.query')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.append')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Node
|
||||
id="assigner-node"
|
||||
data={{
|
||||
title: 'Legacy Assigner',
|
||||
desc: '',
|
||||
type: BlockEnum.VariableAssigner,
|
||||
assigned_variable_selector: [],
|
||||
write_mode: WriteMode.append,
|
||||
} as unknown as AssignerNodeType}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('skips empty v2 operations and resolves system variables through the start node', () => {
|
||||
render(
|
||||
<Node
|
||||
id="assigner-node"
|
||||
data={createData({
|
||||
items: [
|
||||
createOperation({ variable_selector: [] }),
|
||||
createOperation({
|
||||
variable_selector: ['sys', 'query'],
|
||||
operation: WriteMode.append,
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Start:start:sys.query')).toBeInTheDocument()
|
||||
expect(screen.queryByText('undefined:undefined:')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { AssignerNodeOperation, AssignerNodeType } from '../types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Panel from '../panel'
|
||||
import { AssignerNodeInputType, WriteMode } from '../types'
|
||||
|
||||
type MockVarListProps = {
|
||||
readonly: boolean
|
||||
nodeId: string
|
||||
list: AssignerNodeOperation[]
|
||||
onChange: (list: AssignerNodeOperation[]) => void
|
||||
}
|
||||
|
||||
const mockUseConfig = vi.hoisted(() => vi.fn())
|
||||
const mockUseHandleAddOperationItem = vi.hoisted(() => vi.fn())
|
||||
const mockVarListRender = vi.hoisted(() => vi.fn())
|
||||
|
||||
const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({
|
||||
variable_selector: ['node-1', 'count'],
|
||||
input_type: AssignerNodeInputType.variable,
|
||||
operation: WriteMode.overwrite,
|
||||
value: ['node-2', 'result'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useHandleAddOperationItem: () => mockUseHandleAddOperationItem,
|
||||
}))
|
||||
|
||||
vi.mock('../components/var-list', () => ({
|
||||
__esModule: true,
|
||||
default: (props: MockVarListProps) => {
|
||||
mockVarListRender(props)
|
||||
return (
|
||||
<div>
|
||||
<div>{props.list.map(item => item.variable_selector.join('.')).join(',')}</div>
|
||||
<button type="button" onClick={() => props.onChange([createOperation({ variable_selector: ['node-1', 'updated'] })])}>
|
||||
emit-list-change
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const createData = (overrides: Partial<AssignerNodeType> = {}): AssignerNodeType => ({
|
||||
title: 'Assigner',
|
||||
desc: '',
|
||||
type: BlockEnum.VariableAssigner,
|
||||
version: '2',
|
||||
items: [createOperation()],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const panelProps = {} as PanelProps
|
||||
|
||||
describe('assigner/panel', () => {
|
||||
const handleOperationListChanges = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseHandleAddOperationItem.mockReturnValue([
|
||||
createOperation(),
|
||||
createOperation({ variable_selector: [] }),
|
||||
])
|
||||
mockUseConfig.mockReturnValue({
|
||||
readOnly: false,
|
||||
inputs: createData(),
|
||||
handleOperationListChanges,
|
||||
getAssignedVarType: vi.fn(),
|
||||
getToAssignedVarType: vi.fn(),
|
||||
writeModeTypesNum: [],
|
||||
writeModeTypesArr: [],
|
||||
writeModeTypes: [],
|
||||
filterAssignedVar: vi.fn(),
|
||||
filterToAssignedVar: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the resolved config to the variable list and appends operations through the add button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="assigner-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.assigner.variables')).toBeInTheDocument()
|
||||
expect(screen.getByText('node-1.count')).toBeInTheDocument()
|
||||
expect(mockVarListRender).toHaveBeenCalledWith(expect.objectContaining({
|
||||
readonly: false,
|
||||
nodeId: 'assigner-node',
|
||||
list: createData().items,
|
||||
}))
|
||||
|
||||
await user.click(screen.getAllByRole('button')[0]!)
|
||||
|
||||
expect(mockUseHandleAddOperationItem).toHaveBeenCalledWith(createData().items)
|
||||
expect(handleOperationListChanges).toHaveBeenCalledWith([
|
||||
createOperation(),
|
||||
createOperation({ variable_selector: [] }),
|
||||
])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'emit-list-change' }))
|
||||
|
||||
expect(handleOperationListChanges).toHaveBeenCalledWith([
|
||||
createOperation({ variable_selector: ['node-1', 'updated'] }),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { AssignerNodeOperation, AssignerNodeType } from '../types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import useNodeCrud from '../../_base/hooks/use-node-crud'
|
||||
import { AssignerNodeInputType, WriteMode } from '../types'
|
||||
import useSingleRunFormParams from '../use-single-run-form-params'
|
||||
|
||||
vi.mock('../../_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseNodeCrud = vi.mocked(useNodeCrud)
|
||||
|
||||
const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({
|
||||
variable_selector: ['node-1', 'target'],
|
||||
input_type: AssignerNodeInputType.variable,
|
||||
operation: WriteMode.overwrite,
|
||||
value: ['node-2', 'result'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createData = (overrides: Partial<AssignerNodeType> = {}): AssignerNodeType => ({
|
||||
title: 'Assigner',
|
||||
desc: '',
|
||||
type: BlockEnum.VariableAssigner,
|
||||
version: '2',
|
||||
items: [
|
||||
createOperation(),
|
||||
createOperation({ operation: WriteMode.append, value: ['node-3', 'items'] }),
|
||||
createOperation({ operation: WriteMode.clear, value: ['node-4', 'unused'] }),
|
||||
createOperation({ operation: WriteMode.set, input_type: AssignerNodeInputType.constant, value: 'fixed' }),
|
||||
createOperation({ operation: WriteMode.increment, input_type: AssignerNodeInputType.constant, value: 2 }),
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('assigner/use-single-run-form-params', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodeCrud.mockReturnValue({
|
||||
inputs: createData(),
|
||||
setInputs: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useNodeCrud>)
|
||||
})
|
||||
|
||||
it('exposes only variable-driven dependencies in the single-run form', () => {
|
||||
const setRunInputData = vi.fn()
|
||||
const varInputs: InputVar[] = [{
|
||||
label: 'Result',
|
||||
variable: 'result',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
}]
|
||||
const varSelectorsToVarInputs = vi.fn(() => varInputs)
|
||||
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'assigner-node',
|
||||
payload: createData(),
|
||||
runInputData: { result: 'hello' },
|
||||
runInputDataRef: { current: {} },
|
||||
getInputVars: () => [],
|
||||
setRunInputData,
|
||||
toVarInputs: () => [],
|
||||
varSelectorsToVarInputs,
|
||||
}))
|
||||
|
||||
expect(varSelectorsToVarInputs).toHaveBeenCalledWith([
|
||||
['node-2', 'result'],
|
||||
['node-3', 'items'],
|
||||
])
|
||||
expect(result.current.forms).toHaveLength(1)
|
||||
expect(result.current.forms[0].inputs).toEqual(varInputs)
|
||||
expect(result.current.forms[0].values).toEqual({ result: 'hello' })
|
||||
|
||||
result.current.forms[0].onChange({ result: 'updated' })
|
||||
|
||||
expect(setRunInputData).toHaveBeenCalledWith({ result: 'updated' })
|
||||
expect(result.current.getDependentVars()).toEqual([
|
||||
['node-2', 'result'],
|
||||
['node-3', 'items'],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { WriteMode } from '../../types'
|
||||
import OperationSelector from '../operation-selector'
|
||||
|
||||
describe('assigner/operation-selector', () => {
|
||||
it('shows numeric write modes and emits the selected operation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<OperationSelector
|
||||
value={WriteMode.overwrite}
|
||||
onSelect={onSelect}
|
||||
assignedVarType={VarType.number}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={[WriteMode.increment]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
|
||||
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.clear')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.set')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.assigner.operations.+=')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getAllByText('workflow.nodes.assigner.operations.+=').at(-1)!)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({ value: WriteMode.increment, name: WriteMode.increment })
|
||||
})
|
||||
|
||||
it('does not open when the selector is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<OperationSelector
|
||||
value={WriteMode.overwrite}
|
||||
onSelect={vi.fn()}
|
||||
disabled
|
||||
assignedVarType={VarType.string}
|
||||
writeModeTypes={[WriteMode.overwrite]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
|
||||
|
||||
expect(screen.queryByText('workflow.nodes.assigner.operations.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { AssignerNodeInputType, WriteMode } from '../../../types'
|
||||
import VarList from '../index'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
popupFor = 'assigned',
|
||||
onOpen,
|
||||
onChange,
|
||||
}: {
|
||||
popupFor?: string
|
||||
onOpen?: () => void
|
||||
onChange: (value: string[]) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" data-testid={`${popupFor}-picker-trigger`} onClick={onOpen}>
|
||||
open-
|
||||
{popupFor}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(popupFor === 'assigned' ? ['node-b', 'total'] : ['node-c', 'result'])}
|
||||
>
|
||||
select-
|
||||
{popupFor}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../operation-selector', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect: (item: { value: string }) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onSelect({ value: WriteMode.set })}>operation-set</button>
|
||||
<button type="button" onClick={() => onSelect({ value: WriteMode.overwrite })}>operation-overwrite</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createOperation = (
|
||||
overrides: Partial<ComponentProps<typeof VarList>['list'][number]> = {},
|
||||
): ComponentProps<typeof VarList>['list'][number] => ({
|
||||
variable_selector: ['node-a', 'flag'],
|
||||
input_type: AssignerNodeInputType.variable,
|
||||
operation: WriteMode.overwrite,
|
||||
value: ['node-a', 'answer'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderVarList = (props: Partial<ComponentProps<typeof VarList>> = {}) => {
|
||||
const handleChange = vi.fn()
|
||||
const handleOpen = vi.fn()
|
||||
|
||||
const result = render(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-current"
|
||||
list={[]}
|
||||
onChange={handleChange}
|
||||
onOpen={handleOpen}
|
||||
getAssignedVarType={() => VarType.string}
|
||||
getToAssignedVarType={() => VarType.string}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={[WriteMode.increment]}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
|
||||
return {
|
||||
...result,
|
||||
handleChange,
|
||||
handleOpen,
|
||||
}
|
||||
}
|
||||
|
||||
describe('assigner/var-list branches', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('resets operation metadata when the assigned variable changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { handleChange, handleOpen } = renderVarList({
|
||||
list: [createOperation({
|
||||
operation: WriteMode.set,
|
||||
input_type: AssignerNodeInputType.constant,
|
||||
value: 'stale',
|
||||
})],
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('assigned-picker-trigger'))
|
||||
await user.click(screen.getByRole('button', { name: 'select-assigned' }))
|
||||
|
||||
expect(handleOpen).toHaveBeenCalledWith(0)
|
||||
expect(handleChange).toHaveBeenLastCalledWith([
|
||||
createOperation({
|
||||
variable_selector: ['node-b', 'total'],
|
||||
operation: WriteMode.overwrite,
|
||||
input_type: AssignerNodeInputType.variable,
|
||||
value: undefined,
|
||||
}),
|
||||
], ['node-b', 'total'])
|
||||
})
|
||||
|
||||
it('switches back to variable mode when the selected operation no longer requires a constant', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { handleChange } = renderVarList({
|
||||
list: [createOperation({
|
||||
operation: WriteMode.set,
|
||||
input_type: AssignerNodeInputType.constant,
|
||||
value: 'hello',
|
||||
})],
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'operation-overwrite' }))
|
||||
|
||||
expect(handleChange).toHaveBeenLastCalledWith([
|
||||
createOperation({
|
||||
operation: WriteMode.overwrite,
|
||||
input_type: AssignerNodeInputType.variable,
|
||||
value: '',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('updates string and number constant inputs through the inline editors', () => {
|
||||
const { handleChange, rerender } = renderVarList({
|
||||
list: [createOperation({
|
||||
operation: WriteMode.set,
|
||||
input_type: AssignerNodeInputType.constant,
|
||||
value: 1,
|
||||
})],
|
||||
getAssignedVarType: () => VarType.number,
|
||||
getToAssignedVarType: () => VarType.number,
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton'), {
|
||||
target: { value: '2' },
|
||||
})
|
||||
|
||||
expect(handleChange).toHaveBeenLastCalledWith([
|
||||
createOperation({
|
||||
operation: WriteMode.set,
|
||||
input_type: AssignerNodeInputType.constant,
|
||||
value: 2,
|
||||
}),
|
||||
], 2)
|
||||
|
||||
rerender(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-current"
|
||||
list={[createOperation({
|
||||
operation: WriteMode.set,
|
||||
input_type: AssignerNodeInputType.constant,
|
||||
value: 'hello',
|
||||
})]}
|
||||
onChange={handleChange}
|
||||
onOpen={vi.fn()}
|
||||
getAssignedVarType={() => VarType.string}
|
||||
getToAssignedVarType={() => VarType.string}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={[WriteMode.increment]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: 'updated' },
|
||||
})
|
||||
|
||||
expect(handleChange).toHaveBeenLastCalledWith([
|
||||
createOperation({
|
||||
operation: WriteMode.set,
|
||||
input_type: AssignerNodeInputType.constant,
|
||||
value: 'updated',
|
||||
}),
|
||||
], 'updated')
|
||||
})
|
||||
|
||||
it('updates numeric write-mode inputs through the dedicated number field', () => {
|
||||
const { handleChange } = renderVarList({
|
||||
list: [createOperation({
|
||||
operation: WriteMode.increment,
|
||||
value: 2,
|
||||
})],
|
||||
getAssignedVarType: () => VarType.number,
|
||||
getToAssignedVarType: () => VarType.number,
|
||||
writeModeTypesNum: [WriteMode.increment],
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton'), {
|
||||
target: { value: '5' },
|
||||
})
|
||||
|
||||
expect(handleChange).toHaveBeenLastCalledWith([
|
||||
createOperation({
|
||||
operation: WriteMode.increment,
|
||||
value: 5,
|
||||
}),
|
||||
], 5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createNode, resetFixtureCounters } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { AssignerNodeInputType, WriteMode } from '../../../types'
|
||||
import VarList from '../index'
|
||||
|
||||
const sourceNode = createNode({
|
||||
id: 'node-a',
|
||||
data: {
|
||||
type: BlockEnum.Answer,
|
||||
title: 'Answer Node',
|
||||
outputs: {
|
||||
answer: { type: VarType.string },
|
||||
flag: { type: VarType.boolean },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const currentNode = createNode({
|
||||
id: 'node-current',
|
||||
data: {
|
||||
type: BlockEnum.VariableAssigner,
|
||||
title: 'Assigner Node',
|
||||
},
|
||||
})
|
||||
|
||||
const createOperation = (overrides: Partial<ComponentProps<typeof VarList>['list'][number]> = {}) => ({
|
||||
variable_selector: ['node-a', 'flag'],
|
||||
input_type: AssignerNodeInputType.variable,
|
||||
operation: WriteMode.overwrite,
|
||||
value: ['node-a', 'answer'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderVarList = (props: Partial<ComponentProps<typeof VarList>> = {}) => {
|
||||
const handleChange = vi.fn()
|
||||
const handleOpen = vi.fn()
|
||||
|
||||
const result = renderWorkflowFlowComponent(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-current"
|
||||
list={[]}
|
||||
onChange={handleChange}
|
||||
onOpen={handleOpen}
|
||||
getAssignedVarType={() => VarType.string}
|
||||
getToAssignedVarType={() => VarType.string}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={[WriteMode.increment]}
|
||||
{...props}
|
||||
/>,
|
||||
{
|
||||
nodes: [sourceNode, currentNode],
|
||||
edges: [],
|
||||
hooksStoreProps: {},
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
...result,
|
||||
handleChange,
|
||||
handleOpen,
|
||||
}
|
||||
}
|
||||
|
||||
describe('assigner/var-list', () => {
|
||||
beforeEach(() => {
|
||||
resetFixtureCounters()
|
||||
})
|
||||
|
||||
it('renders the empty placeholder when no operations are configured', () => {
|
||||
renderVarList()
|
||||
|
||||
expect(screen.getByText('workflow.nodes.assigner.noVarTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches a boolean assignment to constant mode and updates the selected value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const list = [createOperation()]
|
||||
const { handleChange, rerender } = renderVarList({
|
||||
list,
|
||||
getAssignedVarType: () => VarType.boolean,
|
||||
getToAssignedVarType: () => VarType.boolean,
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
|
||||
await user.click(screen.getAllByText('workflow.nodes.assigner.operations.set').at(-1)!)
|
||||
|
||||
expect(handleChange.mock.lastCall?.[0]).toEqual([
|
||||
createOperation({
|
||||
operation: WriteMode.set,
|
||||
input_type: AssignerNodeInputType.constant,
|
||||
value: false,
|
||||
}),
|
||||
])
|
||||
|
||||
rerender(
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-current"
|
||||
list={[
|
||||
createOperation({
|
||||
operation: WriteMode.set,
|
||||
input_type: AssignerNodeInputType.constant,
|
||||
value: false,
|
||||
}),
|
||||
]}
|
||||
onChange={handleChange}
|
||||
onOpen={vi.fn()}
|
||||
getAssignedVarType={() => VarType.boolean}
|
||||
getToAssignedVarType={() => VarType.boolean}
|
||||
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
|
||||
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
|
||||
writeModeTypesNum={[WriteMode.increment]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('True'))
|
||||
|
||||
expect(handleChange.mock.lastCall?.[0]).toEqual([
|
||||
createOperation({
|
||||
operation: WriteMode.set,
|
||||
input_type: AssignerNodeInputType.constant,
|
||||
value: true,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('opens the assigned-variable picker and removes an operation', () => {
|
||||
const { handleChange, handleOpen } = renderVarList({
|
||||
list: [createOperation()],
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('var-reference-picker-trigger')[0]!)
|
||||
expect(handleOpen).toHaveBeenCalledWith(0)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[buttons.length - 1]!)
|
||||
|
||||
expect(handleChange).toHaveBeenLastCalledWith([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { CodeNodeType } from '../types'
|
||||
import { render } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
import { CodeLanguage } from '../types'
|
||||
|
||||
const createData = (overrides: Partial<CodeNodeType> = {}): CodeNodeType => ({
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
type: BlockEnum.Code,
|
||||
variables: [],
|
||||
code_language: CodeLanguage.javascript,
|
||||
code: 'function main() { return {} }',
|
||||
outputs: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('code/node', () => {
|
||||
it('renders an empty summary container', () => {
|
||||
const { container } = render(
|
||||
<Node
|
||||
id="code-node"
|
||||
data={createData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
295
web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx
Normal file
295
web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CodeNodeType, OutputVar } from '../types'
|
||||
import type useConfig from '../use-config'
|
||||
import type { NodePanelProps, Variable } from '@/app/components/workflow/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import Panel from '../panel'
|
||||
import { CodeLanguage } from '../types'
|
||||
|
||||
const mockUseConfig = vi.hoisted(() => vi.fn())
|
||||
const mockExtractFunctionParams = vi.hoisted(() => vi.fn())
|
||||
const mockExtractReturnType = vi.hoisted(() => vi.fn())
|
||||
const mockCodeEditor = vi.hoisted(() => vi.fn())
|
||||
const mockVarList = vi.hoisted(() => vi.fn())
|
||||
const mockOutputVarList = vi.hoisted(() => vi.fn())
|
||||
const mockRemoveEffectVarConfirm = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../code-parser', () => ({
|
||||
extractFunctionParams: (...args: unknown[]) => mockExtractFunctionParams(...args),
|
||||
extractReturnType: (...args: unknown[]) => mockExtractReturnType(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
readOnly: boolean
|
||||
language: CodeLanguage
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onGenerated: (value: string) => void
|
||||
title: ReactNode
|
||||
}) => {
|
||||
mockCodeEditor(props)
|
||||
return (
|
||||
<div>
|
||||
<div>{props.readOnly ? 'editor:readonly' : 'editor:editable'}</div>
|
||||
<div>{props.language}</div>
|
||||
<div>{props.title}</div>
|
||||
<button type="button" onClick={() => props.onChange('generated code body')}>
|
||||
change-code
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onGenerated('generated signature code')}>
|
||||
generate-code
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/selector', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
value: CodeLanguage
|
||||
onChange: (value: CodeLanguage) => void
|
||||
}) => (
|
||||
<button type="button" onClick={() => props.onChange(CodeLanguage.python3)}>
|
||||
{`language:${props.value}`}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
readonly: boolean
|
||||
list: Variable[]
|
||||
onChange: (list: Variable[]) => void
|
||||
}) => {
|
||||
mockVarList(props)
|
||||
return (
|
||||
<div>
|
||||
<div>{props.readonly ? 'var-list:readonly' : 'var-list:editable'}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onChange([{
|
||||
variable: 'changed',
|
||||
value_selector: ['start', 'changed'],
|
||||
}])}
|
||||
>
|
||||
change-var-list
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/output-var-list', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
readonly: boolean
|
||||
outputs: OutputVar
|
||||
onChange: (outputs: OutputVar) => void
|
||||
onRemove: (name: string) => void
|
||||
}) => {
|
||||
mockOutputVarList(props)
|
||||
return (
|
||||
<div>
|
||||
<div>{props.readonly ? 'output-list:readonly' : 'output-list:editable'}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onChange({
|
||||
next_result: {
|
||||
type: VarType.number,
|
||||
children: null,
|
||||
},
|
||||
})}
|
||||
>
|
||||
change-output-list
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onRemove('result')}>
|
||||
remove-output
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/components/remove-effect-var-confirm', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
isShow: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}) => {
|
||||
mockRemoveEffectVarConfirm(props)
|
||||
return props.isShow
|
||||
? (
|
||||
<div>
|
||||
<button type="button" onClick={props.onCancel}>
|
||||
cancel-remove
|
||||
</button>
|
||||
<button type="button" onClick={props.onConfirm}>
|
||||
confirm-remove
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
},
|
||||
}))
|
||||
|
||||
const createData = (overrides: Partial<CodeNodeType> = {}): CodeNodeType => ({
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
type: BlockEnum.Code,
|
||||
code_language: CodeLanguage.javascript,
|
||||
code: 'function main({ foo }) { return { result: foo } }',
|
||||
variables: [{
|
||||
variable: 'foo',
|
||||
value_selector: ['start', 'foo'],
|
||||
value_type: VarType.string,
|
||||
}],
|
||||
outputs: {
|
||||
result: {
|
||||
type: VarType.string,
|
||||
children: null,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
|
||||
readOnly: false,
|
||||
inputs: createData(),
|
||||
outputKeyOrders: ['result'],
|
||||
handleCodeAndVarsChange: vi.fn(),
|
||||
handleVarListChange: vi.fn(),
|
||||
handleAddVariable: vi.fn(),
|
||||
handleRemoveVariable: vi.fn(),
|
||||
handleSyncFunctionSignature: vi.fn(),
|
||||
handleCodeChange: vi.fn(),
|
||||
handleCodeLanguageChange: vi.fn(),
|
||||
handleVarsChange: vi.fn(),
|
||||
handleAddOutputVariable: vi.fn(),
|
||||
filterVar: vi.fn(() => true),
|
||||
isShowRemoveVarConfirm: true,
|
||||
hideRemoveVarConfirm: vi.fn(),
|
||||
onRemoveVarConfirm: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderPanel = (data: CodeNodeType = createData()) => {
|
||||
const props: NodePanelProps<CodeNodeType> = {
|
||||
id: 'code-node',
|
||||
data,
|
||||
panelProps: {
|
||||
getInputVars: vi.fn(() => []),
|
||||
toVarInputs: vi.fn(() => []),
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
setRunInputData: vi.fn(),
|
||||
runResult: null,
|
||||
},
|
||||
}
|
||||
|
||||
return render(<Panel {...props} />)
|
||||
}
|
||||
|
||||
describe('code/panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExtractFunctionParams.mockReturnValue(['summary', 'count'])
|
||||
mockExtractReturnType.mockReturnValue({
|
||||
result: {
|
||||
type: VarType.string,
|
||||
children: null,
|
||||
},
|
||||
})
|
||||
mockUseConfig.mockReturnValue(createConfigResult())
|
||||
})
|
||||
|
||||
it('renders editable controls and forwards all input, output, and code actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const config = createConfigResult()
|
||||
mockUseConfig.mockReturnValue(config)
|
||||
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('workflow.nodes.code.inputVars')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.code.outputVars')).toBeInTheDocument()
|
||||
expect(screen.getByText('editor:editable')).toBeInTheDocument()
|
||||
expect(screen.getByText('language:javascript')).toBeInTheDocument()
|
||||
|
||||
const addButtons = screen.getAllByTestId('add-button')
|
||||
await user.click(addButtons[0]!)
|
||||
await user.click(screen.getByTestId('sync-button'))
|
||||
await user.click(screen.getByRole('button', { name: 'change-code' }))
|
||||
await user.click(screen.getByRole('button', { name: 'generate-code' }))
|
||||
await user.click(screen.getByRole('button', { name: 'language:javascript' }))
|
||||
await user.click(screen.getByRole('button', { name: 'change-var-list' }))
|
||||
await user.click(screen.getByRole('button', { name: 'change-output-list' }))
|
||||
await user.click(screen.getByRole('button', { name: 'remove-output' }))
|
||||
await user.click(addButtons[1]!)
|
||||
await user.click(screen.getByRole('button', { name: 'cancel-remove' }))
|
||||
await user.click(screen.getByRole('button', { name: 'confirm-remove' }))
|
||||
|
||||
expect(config.handleAddVariable).toHaveBeenCalled()
|
||||
expect(config.handleSyncFunctionSignature).toHaveBeenCalled()
|
||||
expect(config.handleCodeChange).toHaveBeenCalledWith('generated code body')
|
||||
expect(config.handleCodeLanguageChange).toHaveBeenCalledWith(CodeLanguage.python3)
|
||||
expect(config.handleVarListChange).toHaveBeenCalledWith([{
|
||||
variable: 'changed',
|
||||
value_selector: ['start', 'changed'],
|
||||
}])
|
||||
expect(config.handleVarsChange).toHaveBeenCalledWith({
|
||||
next_result: {
|
||||
type: VarType.number,
|
||||
children: null,
|
||||
},
|
||||
})
|
||||
expect(config.handleRemoveVariable).toHaveBeenCalledWith('result')
|
||||
expect(config.handleAddOutputVariable).toHaveBeenCalled()
|
||||
expect(config.hideRemoveVarConfirm).toHaveBeenCalled()
|
||||
expect(config.onRemoveVarConfirm).toHaveBeenCalled()
|
||||
expect(config.handleCodeAndVarsChange).toHaveBeenCalledWith(
|
||||
'generated signature code',
|
||||
[{
|
||||
variable: 'summary',
|
||||
value_selector: [],
|
||||
}, {
|
||||
variable: 'count',
|
||||
value_selector: [],
|
||||
}],
|
||||
{
|
||||
result: {
|
||||
type: VarType.string,
|
||||
children: null,
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(mockExtractFunctionParams).toHaveBeenCalledWith('generated signature code', CodeLanguage.javascript)
|
||||
expect(mockExtractReturnType).toHaveBeenCalledWith('generated signature code', CodeLanguage.javascript)
|
||||
})
|
||||
|
||||
it('removes input actions in readonly mode and passes readonly state to child sections', () => {
|
||||
mockUseConfig.mockReturnValue(createConfigResult({
|
||||
readOnly: true,
|
||||
isShowRemoveVarConfirm: false,
|
||||
}))
|
||||
|
||||
renderPanel()
|
||||
|
||||
expect(screen.queryByTestId('sync-button')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('add-button')).toHaveLength(1)
|
||||
expect(screen.getByText('editor:readonly')).toBeInTheDocument()
|
||||
expect(screen.getByText('var-list:readonly')).toBeInTheDocument()
|
||||
expect(screen.getByText('output-list:readonly')).toBeInTheDocument()
|
||||
expect(mockRemoveEffectVarConfirm).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { CodeNodeType, OutputVar } from '../types'
|
||||
import type { Var, Variable } from '@/app/components/workflow/types'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { fetchNodeDefault, fetchPipelineNodeDefault } from '@/service/workflow'
|
||||
import useOutputVarList from '../../_base/hooks/use-output-var-list'
|
||||
import useVarList from '../../_base/hooks/use-var-list'
|
||||
import { CodeLanguage } from '../types'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-var-list', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-output-var-list', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchNodeDefault: vi.fn(),
|
||||
fetchPipelineNodeDefault: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseNodeCrud = vi.mocked(useNodeCrud)
|
||||
const mockUseVarList = vi.mocked(useVarList)
|
||||
const mockUseOutputVarList = vi.mocked(useOutputVarList)
|
||||
const mockUseStore = vi.mocked(useStore)
|
||||
const mockFetchNodeDefault = vi.mocked(fetchNodeDefault)
|
||||
const mockFetchPipelineNodeDefault = vi.mocked(fetchPipelineNodeDefault)
|
||||
|
||||
const createVariable = (variable: string, valueType: VarType = VarType.string): Variable => ({
|
||||
variable,
|
||||
value_selector: ['start', variable],
|
||||
value_type: valueType,
|
||||
})
|
||||
|
||||
const createOutputs = (name = 'result', type: VarType = VarType.string): OutputVar => ({
|
||||
[name]: {
|
||||
type,
|
||||
children: null,
|
||||
},
|
||||
})
|
||||
|
||||
const createData = (overrides: Partial<CodeNodeType> = {}): CodeNodeType => ({
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
type: BlockEnum.Code,
|
||||
code_language: CodeLanguage.javascript,
|
||||
code: 'function main({ foo }) { return { result: foo } }',
|
||||
variables: [createVariable('foo')],
|
||||
outputs: createOutputs(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('code/use-config', () => {
|
||||
const mockSetInputs = vi.fn()
|
||||
const mockHandleVarListChange = vi.fn()
|
||||
const mockHandleAddVariable = vi.fn()
|
||||
const mockHandleVarsChange = vi.fn()
|
||||
const mockHandleAddOutputVariable = vi.fn()
|
||||
const mockHandleRemoveVariable = vi.fn()
|
||||
const mockHideRemoveVarConfirm = vi.fn()
|
||||
const mockOnRemoveVarConfirm = vi.fn()
|
||||
|
||||
let workflowStoreState: {
|
||||
appId?: string
|
||||
pipelineId?: string
|
||||
nodesDefaultConfigs?: Record<string, CodeNodeType>
|
||||
}
|
||||
let currentInputs: CodeNodeType
|
||||
let javaScriptConfig: CodeNodeType
|
||||
let pythonConfig: CodeNodeType
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
javaScriptConfig = createData({
|
||||
code_language: CodeLanguage.javascript,
|
||||
code: 'function main({ query }) { return { result: query } }',
|
||||
variables: [createVariable('query')],
|
||||
outputs: createOutputs('result'),
|
||||
})
|
||||
pythonConfig = createData({
|
||||
code_language: CodeLanguage.python3,
|
||||
code: 'def main(name: str):\n return {"result": name}',
|
||||
variables: [createVariable('name')],
|
||||
outputs: createOutputs('result'),
|
||||
})
|
||||
currentInputs = createData()
|
||||
workflowStoreState = {
|
||||
appId: undefined,
|
||||
pipelineId: undefined,
|
||||
nodesDefaultConfigs: {
|
||||
[BlockEnum.Code]: createData({
|
||||
code_language: CodeLanguage.javascript,
|
||||
code: 'function main() { return { default_result: "" } }',
|
||||
variables: [],
|
||||
outputs: createOutputs('default_result'),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
nodesReadOnly: false,
|
||||
getNodesReadOnly: () => false,
|
||||
})
|
||||
mockUseNodeCrud.mockImplementation(() => ({
|
||||
inputs: currentInputs,
|
||||
setInputs: mockSetInputs,
|
||||
}))
|
||||
mockUseVarList.mockReturnValue({
|
||||
handleVarListChange: mockHandleVarListChange,
|
||||
handleAddVariable: mockHandleAddVariable,
|
||||
} as ReturnType<typeof useVarList>)
|
||||
mockUseOutputVarList.mockReturnValue({
|
||||
handleVarsChange: mockHandleVarsChange,
|
||||
handleAddVariable: mockHandleAddOutputVariable,
|
||||
handleRemoveVariable: mockHandleRemoveVariable,
|
||||
isShowRemoveVarConfirm: false,
|
||||
hideRemoveVarConfirm: mockHideRemoveVarConfirm,
|
||||
onRemoveVarConfirm: mockOnRemoveVarConfirm,
|
||||
} as ReturnType<typeof useOutputVarList>)
|
||||
mockUseStore.mockImplementation(selector => selector(workflowStoreState as never))
|
||||
mockFetchNodeDefault.mockResolvedValue({ config: javaScriptConfig } as never)
|
||||
mockFetchPipelineNodeDefault.mockResolvedValue({ config: javaScriptConfig } as never)
|
||||
mockFetchNodeDefault
|
||||
.mockResolvedValueOnce({ config: javaScriptConfig } as never)
|
||||
.mockResolvedValueOnce({ config: pythonConfig } as never)
|
||||
mockFetchPipelineNodeDefault
|
||||
.mockResolvedValueOnce({ config: javaScriptConfig } as never)
|
||||
.mockResolvedValueOnce({ config: pythonConfig } as never)
|
||||
})
|
||||
|
||||
it('hydrates node defaults when the code payload is empty and syncs output key order', async () => {
|
||||
currentInputs = createData({
|
||||
code: '',
|
||||
variables: [],
|
||||
outputs: {},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useConfig('code-node', currentInputs))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
code: workflowStoreState.nodesDefaultConfigs?.[BlockEnum.Code]?.code,
|
||||
outputs: workflowStoreState.nodesDefaultConfigs?.[BlockEnum.Code]?.outputs,
|
||||
}))
|
||||
})
|
||||
|
||||
expect(result.current.handleVarListChange).toBe(mockHandleVarListChange)
|
||||
expect(result.current.handleAddVariable).toBe(mockHandleAddVariable)
|
||||
expect(result.current.handleVarsChange).toBe(mockHandleVarsChange)
|
||||
expect(result.current.handleAddOutputVariable).toBe(mockHandleAddOutputVariable)
|
||||
expect(result.current.handleRemoveVariable).toBe(mockHandleRemoveVariable)
|
||||
expect(result.current.hideRemoveVarConfirm).toBe(mockHideRemoveVarConfirm)
|
||||
expect(result.current.onRemoveVarConfirm).toBe(mockOnRemoveVarConfirm)
|
||||
expect(result.current.outputKeyOrders).toEqual(['default_result'])
|
||||
expect(result.current.filterVar({ type: VarType.file } as Var)).toBe(true)
|
||||
expect(result.current.filterVar({ type: VarType.secret } as Var)).toBe(true)
|
||||
})
|
||||
|
||||
it('fetches app and pipeline defaults, switches language, and updates code and output vars together', async () => {
|
||||
workflowStoreState.appId = 'app-1'
|
||||
workflowStoreState.pipelineId = 'pipeline-1'
|
||||
|
||||
const { result } = renderHook(() => useConfig('code-node', currentInputs))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchNodeDefault).toHaveBeenCalledWith('app-1', BlockEnum.Code, { code_language: CodeLanguage.javascript })
|
||||
expect(mockFetchNodeDefault).toHaveBeenCalledWith('app-1', BlockEnum.Code, { code_language: CodeLanguage.python3 })
|
||||
expect(mockFetchPipelineNodeDefault).toHaveBeenCalledWith('pipeline-1', BlockEnum.Code, { code_language: CodeLanguage.javascript })
|
||||
expect(mockFetchPipelineNodeDefault).toHaveBeenCalledWith('pipeline-1', BlockEnum.Code, { code_language: CodeLanguage.python3 })
|
||||
})
|
||||
|
||||
mockSetInputs.mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCodeLanguageChange(CodeLanguage.python3)
|
||||
result.current.handleCodeChange('function main({ bar }) { return { result: bar } }')
|
||||
result.current.handleCodeAndVarsChange(
|
||||
'function main({ amount }) { return { total: amount } }',
|
||||
[createVariable('amount', VarType.number)],
|
||||
createOutputs('total', VarType.number),
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
code_language: CodeLanguage.python3,
|
||||
code: pythonConfig.code,
|
||||
variables: pythonConfig.variables,
|
||||
outputs: pythonConfig.outputs,
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
code: 'function main({ bar }) { return { result: bar } }',
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
code: 'function main({ amount }) { return { total: amount } }',
|
||||
variables: [expect.objectContaining({ variable: 'amount' })],
|
||||
outputs: createOutputs('total', VarType.number),
|
||||
}))
|
||||
expect(result.current.outputKeyOrders).toEqual(['total'])
|
||||
})
|
||||
|
||||
it('syncs javascript and python function signatures and keeps json code unchanged', () => {
|
||||
currentInputs = createData({
|
||||
code_language: CodeLanguage.javascript,
|
||||
code: 'function main() { return { result: "" } }',
|
||||
variables: [createVariable('foo'), createVariable('bar')],
|
||||
})
|
||||
|
||||
const { result, rerender } = renderHook(() => useConfig('code-node', currentInputs))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSyncFunctionSignature()
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
code: 'function main({foo, bar}) { return { result: "" } }',
|
||||
}))
|
||||
|
||||
mockSetInputs.mockClear()
|
||||
currentInputs = createData({
|
||||
code_language: CodeLanguage.python3,
|
||||
code: 'def main():\n return {"result": ""}',
|
||||
variables: [
|
||||
createVariable('text', VarType.string),
|
||||
createVariable('score', VarType.number),
|
||||
createVariable('payload', VarType.object),
|
||||
createVariable('items', VarType.array),
|
||||
createVariable('numbers', VarType.arrayNumber),
|
||||
createVariable('names', VarType.arrayString),
|
||||
createVariable('records', VarType.arrayObject),
|
||||
],
|
||||
})
|
||||
rerender()
|
||||
|
||||
act(() => {
|
||||
result.current.handleSyncFunctionSignature()
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
code: 'def main(text: str, score: float, payload: dict, items: list, numbers: list[float], names: list[str], records: list[dict]):\n return {"result": ""}',
|
||||
}))
|
||||
|
||||
mockSetInputs.mockClear()
|
||||
currentInputs = createData({
|
||||
code_language: CodeLanguage.json,
|
||||
code: '{"result": true}',
|
||||
})
|
||||
rerender()
|
||||
|
||||
act(() => {
|
||||
result.current.handleSyncFunctionSignature()
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
code: '{"result": true}',
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps language changes local when no fetched default exists and preserves existing output order', async () => {
|
||||
currentInputs = createData({
|
||||
outputs: {
|
||||
summary: {
|
||||
type: VarType.string,
|
||||
children: null,
|
||||
},
|
||||
count: {
|
||||
type: VarType.number,
|
||||
children: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
workflowStoreState.appId = undefined
|
||||
workflowStoreState.pipelineId = undefined
|
||||
|
||||
const { result } = renderHook(() => useConfig('code-node', currentInputs))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.outputKeyOrders).toEqual(['summary', 'count'])
|
||||
})
|
||||
|
||||
mockSetInputs.mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCodeLanguageChange(CodeLanguage.python3)
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
code_language: CodeLanguage.python3,
|
||||
code: currentInputs.code,
|
||||
variables: currentInputs.variables,
|
||||
outputs: currentInputs.outputs,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { CodeNodeType } from '../types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
|
||||
import useNodeCrud from '../../_base/hooks/use-node-crud'
|
||||
import { CodeLanguage } from '../types'
|
||||
import useSingleRunFormParams from '../use-single-run-form-params'
|
||||
|
||||
vi.mock('../../_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseNodeCrud = vi.mocked(useNodeCrud)
|
||||
|
||||
const createData = (overrides: Partial<CodeNodeType> = {}): CodeNodeType => ({
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
type: BlockEnum.Code,
|
||||
code_language: CodeLanguage.javascript,
|
||||
code: 'function main({ amount }) { return { result: amount } }',
|
||||
variables: [{
|
||||
variable: 'amount',
|
||||
value_selector: ['start', 'amount'],
|
||||
value_type: VarType.number,
|
||||
}],
|
||||
outputs: {
|
||||
result: {
|
||||
type: VarType.number,
|
||||
children: null,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('code/use-single-run-form-params', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodeCrud.mockReturnValue({
|
||||
inputs: createData(),
|
||||
setInputs: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useNodeCrud>)
|
||||
})
|
||||
|
||||
it('builds a single form, updates run input values, and exposes dependent vars', () => {
|
||||
const setRunInputData = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'code-node',
|
||||
payload: createData(),
|
||||
runInputData: { amount: 1 },
|
||||
runInputDataRef: { current: { amount: 1 } },
|
||||
getInputVars: () => [],
|
||||
setRunInputData,
|
||||
toVarInputs: variables => variables.map(variable => ({
|
||||
type: InputVarType.number,
|
||||
label: variable.variable,
|
||||
variable: variable.variable,
|
||||
required: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
expect(result.current.forms).toEqual([{
|
||||
inputs: [{
|
||||
type: InputVarType.number,
|
||||
label: 'amount',
|
||||
variable: 'amount',
|
||||
required: false,
|
||||
}],
|
||||
values: { amount: 1 },
|
||||
onChange: expect.any(Function),
|
||||
}])
|
||||
|
||||
result.current.forms[0]?.onChange({ amount: 3 })
|
||||
|
||||
expect(setRunInputData).toHaveBeenCalledWith({ amount: 3 })
|
||||
expect(result.current.getDependentVars()).toEqual([['start', 'amount']])
|
||||
expect(result.current.getDependentVar('amount')).toEqual(['start', 'amount'])
|
||||
expect(result.current.getDependentVar('missing')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CustomRunFormProps, DataSourceNodeType } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import BeforeRunForm from '../before-run-form'
|
||||
import useBeforeRunForm from '../hooks/use-before-run-form'
|
||||
|
||||
const mockUseDataSourceStore = vi.hoisted(() => vi.fn())
|
||||
const mockSetCurrentCredentialId = vi.hoisted(() => vi.fn())
|
||||
const mockClearOnlineDocumentData = vi.hoisted(() => vi.fn())
|
||||
const mockClearWebsiteCrawlData = vi.hoisted(() => vi.fn())
|
||||
const mockClearOnlineDriveData = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({
|
||||
useDataSourceStore: () => mockUseDataSourceStore(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store/provider', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/local-file', () => ({
|
||||
__esModule: true,
|
||||
default: ({ allowedExtensions }: { allowedExtensions: string[] }) => <div>{allowedExtensions.join(',')}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => (
|
||||
<button type="button" onClick={() => onCredentialChange('credential-doc')}>online-documents</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => (
|
||||
<button type="button" onClick={() => onCredentialChange('credential-site')}>website-crawl</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => (
|
||||
<button type="button" onClick={() => onCredentialChange('credential-drive')}>online-drive</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks', () => ({
|
||||
useOnlineDocument: () => ({ clearOnlineDocumentData: mockClearOnlineDocumentData }),
|
||||
useWebsiteCrawl: () => ({ clearWebsiteCrawlData: mockClearWebsiteCrawlData }),
|
||||
useOnlineDrive: () => ({ clearOnlineDriveData: mockClearOnlineDriveData }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/panel-wrap', () => ({
|
||||
__esModule: true,
|
||||
default: ({ nodeName, onHide, children }: { nodeName: string, onHide: () => void, children: ReactNode }) => (
|
||||
<div>
|
||||
<div>{nodeName}</div>
|
||||
<button type="button" onClick={onHide}>hide-panel</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-before-run-form', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseBeforeRunForm = vi.mocked(useBeforeRunForm)
|
||||
|
||||
const createData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({
|
||||
title: 'Datasource',
|
||||
desc: '',
|
||||
type: BlockEnum.DataSource,
|
||||
plugin_id: 'plugin-id',
|
||||
provider_type: DatasourceType.localFile,
|
||||
provider_name: 'file',
|
||||
datasource_name: 'local-file',
|
||||
datasource_label: 'Local File',
|
||||
datasource_parameters: {},
|
||||
datasource_configurations: {},
|
||||
fileExtensions: ['pdf', 'md'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createProps = (overrides: Partial<CustomRunFormProps> = {}): CustomRunFormProps => ({
|
||||
nodeId: 'data-source-node',
|
||||
flowId: 'flow-id',
|
||||
flowType: FlowType.ragPipeline,
|
||||
payload: createData(),
|
||||
setRunResult: vi.fn(),
|
||||
setIsRunAfterSingleRun: vi.fn(),
|
||||
isPaused: false,
|
||||
isRunAfterSingleRun: false,
|
||||
onSuccess: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
appendNodeInspectVars: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('data-source/before-run-form', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseDataSourceStore.mockReturnValue({
|
||||
getState: () => ({
|
||||
setCurrentCredentialId: mockSetCurrentCredentialId,
|
||||
}),
|
||||
})
|
||||
mockUseBeforeRunForm.mockReturnValue({
|
||||
isPending: false,
|
||||
handleRunWithSyncDraft: vi.fn(),
|
||||
datasourceType: DatasourceType.localFile,
|
||||
datasourceNodeData: createData(),
|
||||
startRunBtnDisabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the local-file preparation form and triggers run/cancel actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancel = vi.fn()
|
||||
const handleRunWithSyncDraft = vi.fn()
|
||||
|
||||
mockUseBeforeRunForm.mockReturnValueOnce({
|
||||
isPending: false,
|
||||
handleRunWithSyncDraft,
|
||||
datasourceType: DatasourceType.localFile,
|
||||
datasourceNodeData: createData(),
|
||||
startRunBtnDisabled: false,
|
||||
})
|
||||
|
||||
render(<BeforeRunForm {...createProps({ onCancel })} />)
|
||||
|
||||
expect(screen.getByText('Datasource')).toBeInTheDocument()
|
||||
expect(screen.getByText('pdf,md')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
expect(handleRunWithSyncDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears stale online document data before switching credentials', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
mockUseBeforeRunForm.mockReturnValueOnce({
|
||||
isPending: false,
|
||||
handleRunWithSyncDraft: vi.fn(),
|
||||
datasourceType: DatasourceType.onlineDocument,
|
||||
datasourceNodeData: createData({ provider_type: DatasourceType.onlineDocument }),
|
||||
startRunBtnDisabled: true,
|
||||
})
|
||||
|
||||
render(<BeforeRunForm {...createProps({ payload: createData({ provider_type: DatasourceType.onlineDocument }) })} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'online-documents' }))
|
||||
|
||||
expect(mockClearOnlineDocumentData).toHaveBeenCalled()
|
||||
expect(mockSetCurrentCredentialId).toHaveBeenCalledWith('credential-doc')
|
||||
expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('clears website crawl data before switching credentials', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
mockUseBeforeRunForm.mockReturnValueOnce({
|
||||
isPending: false,
|
||||
handleRunWithSyncDraft: vi.fn(),
|
||||
datasourceType: DatasourceType.websiteCrawl,
|
||||
datasourceNodeData: createData({ provider_type: DatasourceType.websiteCrawl }),
|
||||
startRunBtnDisabled: false,
|
||||
})
|
||||
|
||||
render(<BeforeRunForm {...createProps({ payload: createData({ provider_type: DatasourceType.websiteCrawl }) })} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'website-crawl' }))
|
||||
|
||||
expect(mockClearWebsiteCrawlData).toHaveBeenCalled()
|
||||
expect(mockSetCurrentCredentialId).toHaveBeenCalledWith('credential-site')
|
||||
})
|
||||
|
||||
it('clears online drive data before switching credentials', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
mockUseBeforeRunForm.mockReturnValueOnce({
|
||||
isPending: false,
|
||||
handleRunWithSyncDraft: vi.fn(),
|
||||
datasourceType: DatasourceType.onlineDrive,
|
||||
datasourceNodeData: createData({ provider_type: DatasourceType.onlineDrive }),
|
||||
startRunBtnDisabled: false,
|
||||
})
|
||||
|
||||
render(<BeforeRunForm {...createProps({ payload: createData({ provider_type: DatasourceType.onlineDrive }) })} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'online-drive' }))
|
||||
|
||||
expect(mockClearOnlineDriveData).toHaveBeenCalled()
|
||||
expect(mockSetCurrentCredentialId).toHaveBeenCalledWith('credential-drive')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DataSourceNodeType } from '../types'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import useMatchSchemaType, { getMatchedSchemaType } from '../../_base/components/variable/use-match-schema-type'
|
||||
import ToolForm from '../../tool/components/tool-form'
|
||||
import { useConfig } from '../hooks/use-config'
|
||||
import Panel from '../panel'
|
||||
|
||||
const mockWrapStructuredVarItem = vi.hoisted(() => vi.fn((payload: unknown) => payload))
|
||||
|
||||
vi.mock('@/app/components/base/tag-input', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
items,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
items: string[]
|
||||
onChange: (items: string[]) => void
|
||||
placeholder?: string
|
||||
}) => (
|
||||
<button type="button" onClick={() => onChange([...items, 'txt'])}>
|
||||
{placeholder}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
toolParametersToFormSchemas: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils/tool', () => ({
|
||||
wrapStructuredVarItem: (payload: unknown) => mockWrapStructuredVarItem(payload),
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/components/output-vars', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/components/variable/object-child-tree-panel/show', () => ({
|
||||
__esModule: true,
|
||||
default: ({ payload }: { payload: { name: string } }) => <div>{payload.name}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/components/variable/use-match-schema-type', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
getMatchedSchemaType: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../tool/components/tool-form', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(({ onChange, onManageInputField }: { onChange: (value: unknown) => void, onManageInputField?: () => void }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onChange({ dataset: 'docs' })}>tool-form-change</button>
|
||||
<button type="button" onClick={() => onManageInputField?.()}>manage-input-field</button>
|
||||
</div>
|
||||
)),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-config', () => ({
|
||||
useConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseStore = vi.mocked(useStore)
|
||||
const mockUseConfig = vi.mocked(useConfig)
|
||||
const mockToolParametersToFormSchemas = vi.mocked(toolParametersToFormSchemas)
|
||||
const mockUseMatchSchemaType = vi.mocked(useMatchSchemaType)
|
||||
const mockGetMatchedSchemaType = vi.mocked(getMatchedSchemaType)
|
||||
const mockToolForm = vi.mocked(ToolForm)
|
||||
|
||||
const setShowInputFieldPanel = vi.fn()
|
||||
|
||||
const createData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({
|
||||
title: 'Datasource',
|
||||
desc: '',
|
||||
type: BlockEnum.DataSource,
|
||||
plugin_id: 'plugin-1',
|
||||
provider_type: 'remote',
|
||||
provider_name: 'provider',
|
||||
datasource_name: 'source-a',
|
||||
datasource_label: 'Source A',
|
||||
datasource_parameters: {},
|
||||
datasource_configurations: {},
|
||||
fileExtensions: ['pdf'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const panelProps = {} as NodePanelProps<DataSourceNodeType>['panelProps']
|
||||
|
||||
describe('data-source/panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
|
||||
mockUseStore.mockImplementation((selector) => {
|
||||
const select = selector as (state: unknown) => unknown
|
||||
return select({
|
||||
dataSourceList: [{
|
||||
plugin_id: 'plugin-1',
|
||||
is_authorized: true,
|
||||
tools: [{
|
||||
name: 'source-a',
|
||||
parameters: [{ name: 'dataset' }],
|
||||
}],
|
||||
}],
|
||||
pipelineId: 'pipeline-1',
|
||||
setShowInputFieldPanel,
|
||||
})
|
||||
})
|
||||
mockUseConfig.mockReturnValue({
|
||||
handleFileExtensionsChange: vi.fn(),
|
||||
handleParametersChange: vi.fn(),
|
||||
outputSchema: [],
|
||||
hasObjectOutput: false,
|
||||
})
|
||||
mockToolParametersToFormSchemas.mockReturnValue([{ name: 'dataset' }] as never)
|
||||
mockUseMatchSchemaType.mockReturnValue({ schemaTypeDefinitions: {} } as ReturnType<typeof useMatchSchemaType>)
|
||||
mockGetMatchedSchemaType.mockReturnValue('')
|
||||
})
|
||||
|
||||
it('renders the authorized tool form path and forwards parameter changes', () => {
|
||||
const handleParametersChange = vi.fn()
|
||||
mockUseConfig.mockReturnValueOnce({
|
||||
handleFileExtensionsChange: vi.fn(),
|
||||
handleParametersChange,
|
||||
outputSchema: [{
|
||||
name: 'metadata',
|
||||
value: { type: 'object' },
|
||||
}],
|
||||
hasObjectOutput: true,
|
||||
})
|
||||
mockGetMatchedSchemaType.mockReturnValueOnce('json')
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="data-source-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tool-form-change' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'manage-input-field' }))
|
||||
|
||||
expect(handleParametersChange).toHaveBeenCalledWith({ dataset: 'docs' })
|
||||
expect(setShowInputFieldPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockToolForm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
nodeId: 'data-source-node',
|
||||
showManageInputField: true,
|
||||
value: {},
|
||||
}), undefined)
|
||||
expect(screen.getByText('metadata')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the local-file path and updates supported file extensions', () => {
|
||||
const handleFileExtensionsChange = vi.fn()
|
||||
mockUseConfig.mockReturnValueOnce({
|
||||
handleFileExtensionsChange,
|
||||
handleParametersChange: vi.fn(),
|
||||
outputSchema: [],
|
||||
hasObjectOutput: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="data-source-node"
|
||||
data={createData({ provider_type: 'local_file' })}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.dataSource.supportedFileFormatsPlaceholder' }))
|
||||
|
||||
expect(handleFileExtensionsChange).toHaveBeenCalledWith(['pdf', 'txt'])
|
||||
expect(screen.getByText(`datasource_type:${VarType.string}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(`file:${VarType.file}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { CustomRunFormProps, DataSourceNodeType } from '../../types'
|
||||
import type { NodeRunResult, VarInInspect } from '@/types/workflow'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useDataSourceStore, useDataSourceStoreWithSelector } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store'
|
||||
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { useDatasourceSingleRun } from '@/service/use-pipeline'
|
||||
import { useInvalidLastRun } from '@/service/use-workflow'
|
||||
import { fetchNodeInspectVars } from '@/service/workflow'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { useNodeDataUpdate, useNodesSyncDraft } from '../../../../hooks'
|
||||
import useBeforeRunForm from '../use-before-run-form'
|
||||
|
||||
type DataSourceStoreState = {
|
||||
currentNodeIdRef: { current: string }
|
||||
currentCredentialId: string
|
||||
setCurrentCredentialId: (credentialId: string) => void
|
||||
currentCredentialIdRef: { current: string }
|
||||
localFileList: Array<{
|
||||
file: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
extension: string
|
||||
mime_type: string
|
||||
}
|
||||
}>
|
||||
onlineDocuments: Array<Record<string, unknown>>
|
||||
websitePages: Array<Record<string, unknown>>
|
||||
selectedFileIds: string[]
|
||||
onlineDriveFileList: Array<{ id: string, type: string }>
|
||||
bucket?: string
|
||||
}
|
||||
|
||||
type DatasourceSingleRunOptions = {
|
||||
onError?: () => void
|
||||
onSettled?: (data?: NodeRunResult) => void
|
||||
}
|
||||
|
||||
const mockHandleNodeDataUpdate = vi.hoisted(() => vi.fn())
|
||||
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
|
||||
const mockMutateAsync = vi.hoisted(() => vi.fn())
|
||||
const mockInvalidLastRun = vi.hoisted(() => vi.fn())
|
||||
const mockFetchNodeInspectVars = vi.hoisted(() => vi.fn())
|
||||
const mockUseDataSourceStore = vi.hoisted(() => vi.fn())
|
||||
const mockUseDataSourceStoreWithSelector = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
|
||||
return {
|
||||
...actual,
|
||||
useStoreApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodeDataUpdate: vi.fn(),
|
||||
useNodesSyncDraft: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useDatasourceSingleRun: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidLastRun: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchNodeInspectVars: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({
|
||||
useDataSourceStore: vi.fn(),
|
||||
useDataSourceStoreWithSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseStoreApi = vi.mocked(useStoreApi)
|
||||
const mockUseNodeDataUpdateHook = vi.mocked(useNodeDataUpdate)
|
||||
const mockUseNodesSyncDraftHook = vi.mocked(useNodesSyncDraft)
|
||||
const mockUseDatasourceSingleRunHook = vi.mocked(useDatasourceSingleRun)
|
||||
const mockUseInvalidLastRunHook = vi.mocked(useInvalidLastRun)
|
||||
const mockFetchNodeInspectVarsFn = vi.mocked(fetchNodeInspectVars)
|
||||
const mockUseDataSourceStoreHook = vi.mocked(useDataSourceStore)
|
||||
const mockUseDataSourceStoreWithSelectorHook = vi.mocked(useDataSourceStoreWithSelector)
|
||||
|
||||
const createData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({
|
||||
title: 'Datasource',
|
||||
desc: '',
|
||||
type: BlockEnum.DataSource,
|
||||
plugin_id: 'plugin-id',
|
||||
provider_type: DatasourceType.localFile,
|
||||
provider_name: 'provider',
|
||||
datasource_name: 'local-file',
|
||||
datasource_label: 'Local File',
|
||||
datasource_parameters: {},
|
||||
datasource_configurations: {},
|
||||
fileExtensions: ['pdf'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createProps = (overrides: Partial<CustomRunFormProps> = {}): CustomRunFormProps => ({
|
||||
nodeId: 'data-source-node',
|
||||
flowId: 'flow-id',
|
||||
flowType: FlowType.ragPipeline,
|
||||
payload: createData(),
|
||||
setRunResult: vi.fn(),
|
||||
setIsRunAfterSingleRun: vi.fn(),
|
||||
isPaused: false,
|
||||
isRunAfterSingleRun: false,
|
||||
onSuccess: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
appendNodeInspectVars: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('data-source/hooks/use-before-run-form branches', () => {
|
||||
let dataSourceStoreState: DataSourceStoreState
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
dataSourceStoreState = {
|
||||
currentNodeIdRef: { current: 'data-source-node' },
|
||||
currentCredentialId: 'credential-1',
|
||||
setCurrentCredentialId: vi.fn(),
|
||||
currentCredentialIdRef: { current: 'credential-1' },
|
||||
localFileList: [],
|
||||
onlineDocuments: [],
|
||||
websitePages: [],
|
||||
selectedFileIds: [],
|
||||
onlineDriveFileList: [],
|
||||
bucket: 'drive-bucket',
|
||||
}
|
||||
|
||||
mockUseStoreApi.mockReturnValue({
|
||||
getState: () => ({
|
||||
getNodes: () => [{ id: 'data-source-node', data: { title: 'Datasource' } }],
|
||||
}),
|
||||
} as ReturnType<typeof useStoreApi>)
|
||||
|
||||
mockUseNodeDataUpdateHook.mockReturnValue({
|
||||
handleNodeDataUpdate: mockHandleNodeDataUpdate,
|
||||
handleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
} as ReturnType<typeof useNodeDataUpdate>)
|
||||
mockUseNodesSyncDraftHook.mockReturnValue({
|
||||
handleSyncWorkflowDraft: (...args: unknown[]) => {
|
||||
mockHandleSyncWorkflowDraft(...args)
|
||||
const callbacks = args[2] as { onSuccess?: () => void } | undefined
|
||||
callbacks?.onSuccess?.()
|
||||
},
|
||||
} as ReturnType<typeof useNodesSyncDraft>)
|
||||
mockUseDatasourceSingleRunHook.mockReturnValue({
|
||||
mutateAsync: (...args: unknown[]) => mockMutateAsync(...args),
|
||||
isPending: false,
|
||||
} as ReturnType<typeof useDatasourceSingleRun>)
|
||||
mockUseInvalidLastRunHook.mockReturnValue(mockInvalidLastRun)
|
||||
mockFetchNodeInspectVarsFn.mockImplementation((...args: unknown[]) => mockFetchNodeInspectVars(...args))
|
||||
mockUseDataSourceStoreHook.mockImplementation(() => mockUseDataSourceStore())
|
||||
mockUseDataSourceStoreWithSelectorHook.mockImplementation(selector =>
|
||||
mockUseDataSourceStoreWithSelector(selector as unknown as (state: DataSourceStoreState) => unknown))
|
||||
|
||||
mockUseDataSourceStore.mockImplementation(() => ({
|
||||
getState: () => dataSourceStoreState,
|
||||
}))
|
||||
mockUseDataSourceStoreWithSelector.mockImplementation((selector: (state: DataSourceStoreState) => unknown) =>
|
||||
selector(dataSourceStoreState))
|
||||
mockFetchNodeInspectVars.mockResolvedValue([{ name: 'metadata' }] as VarInInspect[])
|
||||
})
|
||||
|
||||
it('derives disabled states for online documents and website crawl sources', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ payload }) => useBeforeRunForm(createProps({ payload })),
|
||||
{
|
||||
initialProps: {
|
||||
payload: createData({ provider_type: DatasourceType.onlineDocument }),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.current.startRunBtnDisabled).toBe(true)
|
||||
|
||||
dataSourceStoreState.onlineDocuments = [{
|
||||
workspace_id: 'workspace-1',
|
||||
id: 'doc-1',
|
||||
title: 'Document',
|
||||
}]
|
||||
rerender({ payload: createData({ provider_type: DatasourceType.onlineDocument }) })
|
||||
expect(result.current.startRunBtnDisabled).toBe(false)
|
||||
|
||||
rerender({ payload: createData({ provider_type: DatasourceType.websiteCrawl }) })
|
||||
expect(result.current.startRunBtnDisabled).toBe(true)
|
||||
|
||||
dataSourceStoreState.websitePages = [{ url: 'https://example.com' }]
|
||||
rerender({ payload: createData({ provider_type: DatasourceType.websiteCrawl }) })
|
||||
expect(result.current.startRunBtnDisabled).toBe(false)
|
||||
})
|
||||
|
||||
it('returns the settled run result directly when chained single-run execution should stop', async () => {
|
||||
dataSourceStoreState.localFileList = [{
|
||||
file: {
|
||||
id: 'file-1',
|
||||
name: 'doc.pdf',
|
||||
type: 'document',
|
||||
size: 12,
|
||||
extension: 'pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
}]
|
||||
|
||||
mockMutateAsync.mockImplementation((_payload: unknown, options: DatasourceSingleRunOptions) => {
|
||||
options.onSettled?.({ status: NodeRunningStatus.Succeeded } as NodeRunResult)
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
|
||||
const props = createProps({
|
||||
isRunAfterSingleRun: true,
|
||||
payload: createData({
|
||||
_singleRunningStatus: NodeRunningStatus.Running,
|
||||
} as Partial<DataSourceNodeType>),
|
||||
})
|
||||
const { result } = renderHook(() => useBeforeRunForm(props))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleRunWithSyncDraft()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(props.setRunResult).toHaveBeenCalledWith({ status: NodeRunningStatus.Succeeded })
|
||||
expect(mockFetchNodeInspectVars).not.toHaveBeenCalled()
|
||||
expect(props.onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('builds online document datasource info before running', async () => {
|
||||
dataSourceStoreState.onlineDocuments = [{
|
||||
workspace_id: 'workspace-1',
|
||||
id: 'doc-1',
|
||||
title: 'Document',
|
||||
url: 'https://example.com/doc',
|
||||
}]
|
||||
|
||||
mockMutateAsync.mockImplementation((payload: unknown, options: DatasourceSingleRunOptions) => {
|
||||
options.onSettled?.({ status: NodeRunningStatus.Succeeded } as NodeRunResult)
|
||||
return Promise.resolve(payload)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBeforeRunForm(createProps({
|
||||
payload: createData({ provider_type: DatasourceType.onlineDocument }),
|
||||
})))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleRunWithSyncDraft()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({
|
||||
datasource_type: DatasourceType.onlineDocument,
|
||||
datasource_info: {
|
||||
workspace_id: 'workspace-1',
|
||||
page: {
|
||||
id: 'doc-1',
|
||||
title: 'Document',
|
||||
url: 'https://example.com/doc',
|
||||
},
|
||||
credential_id: 'credential-1',
|
||||
},
|
||||
}), expect.any(Object))
|
||||
})
|
||||
|
||||
it('builds website crawl datasource info and skips the failure update while paused', async () => {
|
||||
dataSourceStoreState.websitePages = [{
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
}]
|
||||
|
||||
mockMutateAsync.mockImplementation((payload: unknown, options: DatasourceSingleRunOptions) => {
|
||||
options.onError?.()
|
||||
return Promise.resolve(payload)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBeforeRunForm(createProps({
|
||||
isPaused: true,
|
||||
payload: createData({ provider_type: DatasourceType.websiteCrawl }),
|
||||
})))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleRunWithSyncDraft()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({
|
||||
datasource_type: DatasourceType.websiteCrawl,
|
||||
datasource_info: {
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
credential_id: 'credential-1',
|
||||
},
|
||||
}), expect.any(Object))
|
||||
expect(mockInvalidLastRun).toHaveBeenCalled()
|
||||
expect(mockHandleNodeDataUpdate).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
_singleRunningStatus: NodeRunningStatus.Failed,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { CustomRunFormProps, DataSourceNodeType } from '../../types'
|
||||
import type { NodeRunResult, VarInInspect } from '@/types/workflow'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useDataSourceStore, useDataSourceStoreWithSelector } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store'
|
||||
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { useDatasourceSingleRun } from '@/service/use-pipeline'
|
||||
import { useInvalidLastRun } from '@/service/use-workflow'
|
||||
import { fetchNodeInspectVars } from '@/service/workflow'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { useNodeDataUpdate, useNodesSyncDraft } from '../../../../hooks'
|
||||
import useBeforeRunForm from '../use-before-run-form'
|
||||
|
||||
type DataSourceStoreState = {
|
||||
currentNodeIdRef: { current: string }
|
||||
currentCredentialId: string
|
||||
setCurrentCredentialId: (credentialId: string) => void
|
||||
currentCredentialIdRef: { current: string }
|
||||
localFileList: Array<{
|
||||
file: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
extension: string
|
||||
mime_type: string
|
||||
}
|
||||
}>
|
||||
onlineDocuments: Array<Record<string, unknown>>
|
||||
websitePages: Array<Record<string, unknown>>
|
||||
selectedFileIds: string[]
|
||||
onlineDriveFileList: Array<{ id: string, type: string }>
|
||||
bucket?: string
|
||||
}
|
||||
|
||||
type DatasourceSingleRunOptions = {
|
||||
onError?: () => void
|
||||
onSettled?: (data?: NodeRunResult) => void
|
||||
}
|
||||
|
||||
const mockHandleNodeDataUpdate = vi.hoisted(() => vi.fn())
|
||||
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
|
||||
const mockMutateAsync = vi.hoisted(() => vi.fn())
|
||||
const mockInvalidLastRun = vi.hoisted(() => vi.fn())
|
||||
const mockFetchNodeInspectVars = vi.hoisted(() => vi.fn())
|
||||
const mockUseDataSourceStore = vi.hoisted(() => vi.fn())
|
||||
const mockUseDataSourceStoreWithSelector = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
|
||||
return {
|
||||
...actual,
|
||||
useStoreApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodeDataUpdate: vi.fn(),
|
||||
useNodesSyncDraft: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useDatasourceSingleRun: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidLastRun: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchNodeInspectVars: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({
|
||||
useDataSourceStore: vi.fn(),
|
||||
useDataSourceStoreWithSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseStoreApi = vi.mocked(useStoreApi)
|
||||
const mockUseNodeDataUpdateHook = vi.mocked(useNodeDataUpdate)
|
||||
const mockUseNodesSyncDraftHook = vi.mocked(useNodesSyncDraft)
|
||||
const mockUseDatasourceSingleRunHook = vi.mocked(useDatasourceSingleRun)
|
||||
const mockUseInvalidLastRunHook = vi.mocked(useInvalidLastRun)
|
||||
const mockFetchNodeInspectVarsFn = vi.mocked(fetchNodeInspectVars)
|
||||
const mockUseDataSourceStoreHook = vi.mocked(useDataSourceStore)
|
||||
const mockUseDataSourceStoreWithSelectorHook = vi.mocked(useDataSourceStoreWithSelector)
|
||||
|
||||
const createData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({
|
||||
title: 'Datasource',
|
||||
desc: '',
|
||||
type: BlockEnum.DataSource,
|
||||
plugin_id: 'plugin-id',
|
||||
provider_type: DatasourceType.localFile,
|
||||
provider_name: 'provider',
|
||||
datasource_name: 'local-file',
|
||||
datasource_label: 'Local File',
|
||||
datasource_parameters: {},
|
||||
datasource_configurations: {},
|
||||
fileExtensions: ['pdf'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createProps = (overrides: Partial<CustomRunFormProps> = {}): CustomRunFormProps => ({
|
||||
nodeId: 'data-source-node',
|
||||
flowId: 'flow-id',
|
||||
flowType: FlowType.ragPipeline,
|
||||
payload: createData(),
|
||||
setRunResult: vi.fn(),
|
||||
setIsRunAfterSingleRun: vi.fn(),
|
||||
isPaused: false,
|
||||
isRunAfterSingleRun: false,
|
||||
onSuccess: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
appendNodeInspectVars: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('data-source/hooks/use-before-run-form', () => {
|
||||
let dataSourceStoreState: DataSourceStoreState
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
dataSourceStoreState = {
|
||||
currentNodeIdRef: { current: 'data-source-node' },
|
||||
currentCredentialId: 'credential-1',
|
||||
setCurrentCredentialId: vi.fn(),
|
||||
currentCredentialIdRef: { current: 'credential-1' },
|
||||
localFileList: [],
|
||||
onlineDocuments: [],
|
||||
websitePages: [],
|
||||
selectedFileIds: [],
|
||||
onlineDriveFileList: [],
|
||||
bucket: 'drive-bucket',
|
||||
}
|
||||
|
||||
mockUseStoreApi.mockReturnValue({
|
||||
getState: () => ({
|
||||
getNodes: () => [
|
||||
{
|
||||
id: 'data-source-node',
|
||||
data: {
|
||||
title: 'Datasource',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as ReturnType<typeof useStoreApi>)
|
||||
|
||||
mockUseNodeDataUpdateHook.mockReturnValue({
|
||||
handleNodeDataUpdate: mockHandleNodeDataUpdate,
|
||||
handleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
} as ReturnType<typeof useNodeDataUpdate>)
|
||||
mockUseNodesSyncDraftHook.mockReturnValue({
|
||||
handleSyncWorkflowDraft: (...args: unknown[]) => {
|
||||
mockHandleSyncWorkflowDraft(...args)
|
||||
const callbacks = args[2] as { onSuccess?: () => void } | undefined
|
||||
callbacks?.onSuccess?.()
|
||||
},
|
||||
} as ReturnType<typeof useNodesSyncDraft>)
|
||||
mockUseDatasourceSingleRunHook.mockReturnValue({
|
||||
mutateAsync: (...args: unknown[]) => mockMutateAsync(...args),
|
||||
isPending: false,
|
||||
} as ReturnType<typeof useDatasourceSingleRun>)
|
||||
mockUseInvalidLastRunHook.mockReturnValue(mockInvalidLastRun)
|
||||
mockFetchNodeInspectVarsFn.mockImplementation((...args: unknown[]) => mockFetchNodeInspectVars(...args))
|
||||
mockUseDataSourceStoreHook.mockImplementation(() => mockUseDataSourceStore())
|
||||
mockUseDataSourceStoreWithSelectorHook.mockImplementation(selector =>
|
||||
mockUseDataSourceStoreWithSelector(selector as unknown as (state: DataSourceStoreState) => unknown))
|
||||
|
||||
mockUseDataSourceStore.mockImplementation(() => ({
|
||||
getState: () => dataSourceStoreState,
|
||||
}))
|
||||
mockUseDataSourceStoreWithSelector.mockImplementation((selector: (state: DataSourceStoreState) => unknown) =>
|
||||
selector(dataSourceStoreState))
|
||||
mockFetchNodeInspectVars.mockResolvedValue([{ name: 'metadata' }] as VarInInspect[])
|
||||
})
|
||||
|
||||
it('derives the run button disabled state from the selected datasource payload', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ payload }) => useBeforeRunForm(createProps({ payload })),
|
||||
{
|
||||
initialProps: {
|
||||
payload: createData(),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.current.startRunBtnDisabled).toBe(true)
|
||||
|
||||
dataSourceStoreState.localFileList = [{
|
||||
file: {
|
||||
id: 'file-1',
|
||||
name: 'doc.pdf',
|
||||
type: 'document',
|
||||
size: 12,
|
||||
extension: 'pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
}]
|
||||
rerender({ payload: createData() })
|
||||
expect(result.current.startRunBtnDisabled).toBe(false)
|
||||
|
||||
dataSourceStoreState.selectedFileIds = []
|
||||
rerender({
|
||||
payload: createData({
|
||||
provider_type: DatasourceType.onlineDrive,
|
||||
}),
|
||||
})
|
||||
expect(result.current.startRunBtnDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('syncs the draft, runs the datasource, and appends inspect vars on success', async () => {
|
||||
dataSourceStoreState.localFileList = [{
|
||||
file: {
|
||||
id: 'file-1',
|
||||
name: 'doc.pdf',
|
||||
type: 'document',
|
||||
size: 12,
|
||||
extension: 'pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
}]
|
||||
|
||||
mockMutateAsync.mockImplementation((payload: unknown, options: DatasourceSingleRunOptions) => {
|
||||
options.onSettled?.({ status: NodeRunningStatus.Succeeded } as NodeRunResult)
|
||||
return Promise.resolve(payload)
|
||||
})
|
||||
|
||||
const props = createProps()
|
||||
const { result } = renderHook(() => useBeforeRunForm(props))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleRunWithSyncDraft()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(props.setIsRunAfterSingleRun).toHaveBeenCalledWith(true)
|
||||
expect(mockHandleNodeDataUpdate).toHaveBeenNthCalledWith(1, {
|
||||
id: 'data-source-node',
|
||||
data: expect.objectContaining({
|
||||
_singleRunningStatus: NodeRunningStatus.Running,
|
||||
}),
|
||||
})
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({
|
||||
pipeline_id: 'flow-id',
|
||||
start_node_id: 'data-source-node',
|
||||
datasource_type: DatasourceType.localFile,
|
||||
datasource_info: expect.objectContaining({
|
||||
related_id: 'file-1',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
}),
|
||||
}), expect.any(Object))
|
||||
expect(mockFetchNodeInspectVars).toHaveBeenCalledWith(FlowType.ragPipeline, 'flow-id', 'data-source-node')
|
||||
expect(props.appendNodeInspectVars).toHaveBeenCalledWith('data-source-node', [{ name: 'metadata' }], [
|
||||
{
|
||||
id: 'data-source-node',
|
||||
data: {
|
||||
title: 'Datasource',
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(props.onSuccess).toHaveBeenCalled()
|
||||
expect(mockHandleNodeDataUpdate).toHaveBeenLastCalledWith({
|
||||
id: 'data-source-node',
|
||||
data: expect.objectContaining({
|
||||
_isSingleRun: false,
|
||||
_singleRunningStatus: NodeRunningStatus.Succeeded,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('marks the last run invalid and updates the node to failed when the single run errors', async () => {
|
||||
dataSourceStoreState.selectedFileIds = ['drive-file-1']
|
||||
dataSourceStoreState.onlineDriveFileList = [{
|
||||
id: 'drive-file-1',
|
||||
type: 'file',
|
||||
}]
|
||||
|
||||
mockMutateAsync.mockImplementation((_payload: unknown, options: DatasourceSingleRunOptions) => {
|
||||
options.onError?.()
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBeforeRunForm(createProps({
|
||||
payload: createData({
|
||||
provider_type: DatasourceType.onlineDrive,
|
||||
}),
|
||||
})))
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleRunWithSyncDraft()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(mockInvalidLastRun).toHaveBeenCalled()
|
||||
expect(mockHandleNodeDataUpdate).toHaveBeenLastCalledWith({
|
||||
id: 'data-source-node',
|
||||
data: expect.objectContaining({
|
||||
_isSingleRun: false,
|
||||
_singleRunningStatus: NodeRunningStatus.Failed,
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { DocExtractorNodeType } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
|
||||
return {
|
||||
...actual,
|
||||
useNodes: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
|
||||
VariableLabelInNode: ({
|
||||
variables,
|
||||
nodeTitle,
|
||||
nodeType,
|
||||
}: {
|
||||
variables: string[]
|
||||
nodeTitle?: string
|
||||
nodeType?: BlockEnum
|
||||
}) => <div>{`${nodeTitle}:${nodeType}:${variables.join('.')}`}</div>,
|
||||
}))
|
||||
|
||||
const mockUseNodes = vi.mocked(useNodes)
|
||||
|
||||
const createData = (overrides: Partial<DocExtractorNodeType> = {}): DocExtractorNodeType => ({
|
||||
title: 'Document Extractor',
|
||||
desc: '',
|
||||
type: BlockEnum.DocExtractor,
|
||||
variable_selector: ['node-1', 'files'],
|
||||
is_array_file: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('document-extractor/node', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodes.mockReturnValue([
|
||||
{
|
||||
id: 'node-1',
|
||||
data: {
|
||||
title: 'Input Files',
|
||||
type: BlockEnum.Start,
|
||||
},
|
||||
},
|
||||
] as ReturnType<typeof useNodes>)
|
||||
})
|
||||
|
||||
it('renders nothing when no input variable is configured', () => {
|
||||
const { container } = render(
|
||||
<Node
|
||||
id="doc-node"
|
||||
data={createData({ variable_selector: [] })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders the selected input variable label', () => {
|
||||
render(
|
||||
<Node
|
||||
id="doc-node"
|
||||
data={createData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.docExtractor.inputVar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Input Files:start:node-1.files')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DocExtractorNodeType } from '../types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import Panel from '../panel'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
let mockLocale = 'en-US'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
|
||||
__esModule: true,
|
||||
default: ({ title, children }: { title: ReactNode, children: ReactNode }) => (
|
||||
<div>
|
||||
<div>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>split</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (value: string[]) => void
|
||||
}) => <button type="button" onClick={() => onChange(['node-1', 'files'])}>pick-file-var</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-help-link', () => ({
|
||||
useNodeHelpLink: () => 'https://docs.example.com/document-extractor',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileSupportTypes: () => ({
|
||||
data: {
|
||||
allowed_extensions: ['PDF', 'md', 'md', 'DOCX'],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => mockLocale,
|
||||
}))
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseConfig = vi.mocked(useConfig)
|
||||
|
||||
const createData = (overrides: Partial<DocExtractorNodeType> = {}): DocExtractorNodeType => ({
|
||||
title: 'Document Extractor',
|
||||
desc: '',
|
||||
type: BlockEnum.DocExtractor,
|
||||
variable_selector: ['node-1', 'files'],
|
||||
is_array_file: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
|
||||
readOnly: false,
|
||||
inputs: createData(),
|
||||
handleVarChanges: vi.fn(),
|
||||
filterVar: () => true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const panelProps: PanelProps = {
|
||||
getInputVars: vi.fn(() => []),
|
||||
toVarInputs: vi.fn(() => []),
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
setRunInputData: vi.fn(),
|
||||
runResult: null,
|
||||
}
|
||||
|
||||
describe('document-extractor/panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLocale = 'en-US'
|
||||
mockUseConfig.mockReturnValue(createConfigResult())
|
||||
})
|
||||
|
||||
it('wires variable changes and renders supported file types for english locales', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleVarChanges = vi.fn()
|
||||
|
||||
mockUseConfig.mockReturnValueOnce(createConfigResult({
|
||||
inputs: createData({ is_array_file: false }),
|
||||
handleVarChanges,
|
||||
}))
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="doc-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'pick-file-var' }))
|
||||
|
||||
expect(handleVarChanges).toHaveBeenCalledWith(['node-1', 'files'])
|
||||
expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf, markdown, docx"}')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'workflow.nodes.docExtractor.learnMore' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.example.com/document-extractor',
|
||||
)
|
||||
expect(screen.getByText('text:string')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses chinese separators and array output types when the input is an array of files', () => {
|
||||
mockLocale = LanguagesSupported[1]
|
||||
mockUseConfig.mockReturnValueOnce(createConfigResult({
|
||||
inputs: createData({ is_array_file: true }),
|
||||
}))
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="doc-node"
|
||||
data={createData({ is_array_file: true })}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf、 markdown、 docx"}')).toBeInTheDocument()
|
||||
expect(screen.getByText('text:array[string]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { DocExtractorNodeType } from '../types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useNodesReadOnly,
|
||||
useWorkflow,
|
||||
useWorkflowVariables,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
const mockUseStoreApi = vi.mocked(useStoreApi)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseNodeCrud = vi.mocked(useNodeCrud)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
const mockUseWorkflow = vi.mocked(useWorkflow)
|
||||
const mockUseWorkflowVariables = vi.mocked(useWorkflowVariables)
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
|
||||
return {
|
||||
...actual,
|
||||
useStoreApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useIsChatMode: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useWorkflow: vi.fn(),
|
||||
useWorkflowVariables: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const setInputs = vi.fn()
|
||||
const getCurrentVariableType = vi.fn()
|
||||
|
||||
const createData = (overrides: Partial<DocExtractorNodeType> = {}): DocExtractorNodeType => ({
|
||||
title: 'Document Extractor',
|
||||
desc: '',
|
||||
type: BlockEnum.DocExtractor,
|
||||
variable_selector: ['node-1', 'files'],
|
||||
is_array_file: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('document-extractor/use-config', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
mockUseWorkflow.mockReturnValue({
|
||||
getBeforeNodesInSameBranch: vi.fn(() => [{ id: 'start-node' }]),
|
||||
} as unknown as ReturnType<typeof useWorkflow>)
|
||||
mockUseWorkflowVariables.mockReturnValue({
|
||||
getCurrentVariableType,
|
||||
} as unknown as ReturnType<typeof useWorkflowVariables>)
|
||||
mockUseStoreApi.mockReturnValue({
|
||||
getState: () => ({
|
||||
getNodes: () => [
|
||||
{ id: 'doc-node', parentId: 'loop-1', data: { type: BlockEnum.DocExtractor } },
|
||||
{ id: 'loop-1', data: { type: BlockEnum.Loop } },
|
||||
],
|
||||
}),
|
||||
} as ReturnType<typeof useStoreApi>)
|
||||
mockUseNodeCrud.mockReturnValue({
|
||||
inputs: createData(),
|
||||
setInputs,
|
||||
} as ReturnType<typeof useNodeCrud>)
|
||||
})
|
||||
|
||||
it('updates the selected variable and tracks array file output types', () => {
|
||||
getCurrentVariableType.mockReturnValue(VarType.arrayFile)
|
||||
|
||||
const { result } = renderHook(() => useConfig('doc-node', createData()))
|
||||
|
||||
result.current.handleVarChanges(['node-2', 'files'])
|
||||
|
||||
expect(getCurrentVariableType).toHaveBeenCalled()
|
||||
expect(setInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variable_selector: ['node-2', 'files'],
|
||||
is_array_file: true,
|
||||
}))
|
||||
})
|
||||
|
||||
it('only accepts file variables in the picker filter', () => {
|
||||
const { result } = renderHook(() => useConfig('doc-node', createData()))
|
||||
|
||||
expect(result.current.readOnly).toBe(false)
|
||||
expect(result.current.filterVar({ type: VarType.file } as never)).toBe(true)
|
||||
expect(result.current.filterVar({ type: VarType.arrayFile } as never)).toBe(true)
|
||||
expect(result.current.filterVar({ type: VarType.string } as never)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { DocExtractorNodeType } from '../types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import useSingleRunFormParams from '../use-single-run-form-params'
|
||||
|
||||
const createData = (overrides: Partial<DocExtractorNodeType> = {}): DocExtractorNodeType => ({
|
||||
title: 'Document Extractor',
|
||||
desc: '',
|
||||
type: BlockEnum.DocExtractor,
|
||||
variable_selector: ['start', 'files'],
|
||||
is_array_file: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('document-extractor/use-single-run-form-params', () => {
|
||||
it('exposes a single files form and updates run input values', () => {
|
||||
const setRunInputData = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'doc-node',
|
||||
payload: createData(),
|
||||
runInputData: { files: ['old-file'] },
|
||||
runInputDataRef: { current: {} },
|
||||
getInputVars: () => [],
|
||||
setRunInputData,
|
||||
toVarInputs: () => [],
|
||||
}))
|
||||
|
||||
expect(result.current.forms).toHaveLength(1)
|
||||
expect(result.current.forms[0].inputs).toEqual([
|
||||
expect.objectContaining({
|
||||
variable: 'files',
|
||||
required: true,
|
||||
}),
|
||||
])
|
||||
|
||||
result.current.forms[0].onChange({ files: ['new-file'] })
|
||||
|
||||
expect(setRunInputData).toHaveBeenCalledWith({ files: ['new-file'] })
|
||||
expect(result.current.getDependentVars()).toEqual([['start', 'files']])
|
||||
expect(result.current.getDependentVar('files')).toEqual(['start', 'files'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { EndNodeType } from '../types'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Panel from '../panel'
|
||||
|
||||
const mockUseConfig = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
const createData = (overrides: Partial<EndNodeType> = {}): EndNodeType => ({
|
||||
title: 'End',
|
||||
desc: '',
|
||||
type: BlockEnum.End,
|
||||
outputs: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('EndPanel', () => {
|
||||
const handleVarListChange = vi.fn()
|
||||
const handleAddVariable = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseConfig.mockReturnValue({
|
||||
readOnly: false,
|
||||
inputs: createData(),
|
||||
handleVarListChange,
|
||||
handleAddVariable,
|
||||
})
|
||||
})
|
||||
|
||||
it('should show the output field and allow adding output variables when writable', () => {
|
||||
render(<Panel id="end-node" data={createData()} panelProps={{} as PanelProps} />)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.end.output.variable')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('add-button'))
|
||||
|
||||
expect(handleAddVariable).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should hide the add action when the node is read-only', () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
readOnly: true,
|
||||
inputs: createData(),
|
||||
handleVarListChange,
|
||||
handleAddVariable,
|
||||
})
|
||||
|
||||
render(<Panel id="end-node" data={createData()} panelProps={{} as PanelProps} />)
|
||||
|
||||
expect(screen.queryByTestId('add-button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { EndNodeType } from '../types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
|
||||
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
|
||||
const mockUseVarList = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: () => mockUseNodesReadOnly(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseNodeCrud(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-var-list', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseVarList(...args),
|
||||
}))
|
||||
|
||||
const createPayload = (overrides: Partial<EndNodeType> = {}): EndNodeType => ({
|
||||
title: 'End',
|
||||
desc: '',
|
||||
type: BlockEnum.End,
|
||||
outputs: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('end/use-config', () => {
|
||||
const mockHandleVarListChange = vi.fn()
|
||||
const mockHandleAddVariable = vi.fn()
|
||||
const mockSetInputs = vi.fn()
|
||||
let currentInputs: EndNodeType
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentInputs = createPayload()
|
||||
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true })
|
||||
mockUseNodeCrud.mockReturnValue({
|
||||
inputs: currentInputs,
|
||||
setInputs: mockSetInputs,
|
||||
})
|
||||
mockUseVarList.mockReturnValue({
|
||||
handleVarListChange: mockHandleVarListChange,
|
||||
handleAddVariable: mockHandleAddVariable,
|
||||
})
|
||||
})
|
||||
|
||||
it('should build var-list handlers against outputs and surface the readonly state', () => {
|
||||
const { result } = renderHook(() => useConfig('end-node', currentInputs))
|
||||
const config = mockUseVarList.mock.calls[0][0] as { setInputs: (inputs: EndNodeType) => void }
|
||||
|
||||
expect(mockUseVarList).toHaveBeenCalledWith(expect.objectContaining({
|
||||
inputs: currentInputs,
|
||||
setInputs: expect.any(Function),
|
||||
varKey: 'outputs',
|
||||
}))
|
||||
expect(result.current.readOnly).toBe(true)
|
||||
expect(result.current.handleVarListChange).toBe(mockHandleVarListChange)
|
||||
expect(result.current.handleAddVariable).toBe(mockHandleAddVariable)
|
||||
|
||||
act(() => {
|
||||
config.setInputs(createPayload({
|
||||
outputs: currentInputs.outputs,
|
||||
}))
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
outputs: currentInputs.outputs,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { HttpNodeType } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
import { AuthorizationType, BodyType, Method } from '../types'
|
||||
|
||||
const mockReadonlyInputWithSelectVar = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/readonly-input-with-select-var', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { value: string, nodeId: string, className?: string }) => {
|
||||
mockReadonlyInputWithSelectVar(props)
|
||||
return <div data-testid="readonly-input">{props.value}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const createData = (overrides: Partial<HttpNodeType> = {}): HttpNodeType => ({
|
||||
title: 'HTTP Request',
|
||||
desc: '',
|
||||
type: BlockEnum.HttpRequest,
|
||||
variables: [],
|
||||
method: Method.get,
|
||||
url: 'https://api.example.com',
|
||||
authorization: { type: AuthorizationType.none },
|
||||
headers: '',
|
||||
params: '',
|
||||
body: { type: BodyType.none, data: [] },
|
||||
timeout: { connect: 5, read: 10, write: 15 },
|
||||
ssl_verify: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('http/node', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the request method and forwards the URL to the readonly input', () => {
|
||||
render(
|
||||
<Node
|
||||
id="http-node"
|
||||
data={createData({
|
||||
method: Method.post,
|
||||
url: 'https://api.example.com/users',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('post')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('readonly-input')).toHaveTextContent('https://api.example.com/users')
|
||||
expect(mockReadonlyInputWithSelectVar).toHaveBeenCalledWith(expect.objectContaining({
|
||||
nodeId: 'http-node',
|
||||
value: 'https://api.example.com/users',
|
||||
}))
|
||||
})
|
||||
|
||||
it('renders nothing when the request URL is empty', () => {
|
||||
const { container } = render(
|
||||
<Node
|
||||
id="http-node"
|
||||
data={createData({ url: '' })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
295
web/app/components/workflow/nodes/http/__tests__/panel.spec.tsx
Normal file
295
web/app/components/workflow/nodes/http/__tests__/panel.spec.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HttpNodeType } from '../types'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Panel from '../panel'
|
||||
import { AuthorizationType, BodyPayloadValueType, BodyType, Method } from '../types'
|
||||
|
||||
const mockUseConfig = vi.hoisted(() => vi.fn())
|
||||
const mockAuthorizationModal = vi.hoisted(() => vi.fn())
|
||||
const mockCurlPanel = vi.hoisted(() => vi.fn())
|
||||
const mockApiInput = vi.hoisted(() => vi.fn())
|
||||
const mockKeyValue = vi.hoisted(() => vi.fn())
|
||||
const mockEditBody = vi.hoisted(() => vi.fn())
|
||||
const mockTimeout = vi.hoisted(() => vi.fn())
|
||||
|
||||
type ApiInputProps = {
|
||||
method: Method
|
||||
url: string
|
||||
onMethodChange: (method: Method) => void
|
||||
onUrlChange: (url: string) => void
|
||||
}
|
||||
|
||||
type KeyValueProps = {
|
||||
nodeId: string
|
||||
list: Array<{ key: string, value: string }>
|
||||
onChange: (value: Array<{ key: string, value: string }>) => void
|
||||
onAdd: () => void
|
||||
}
|
||||
|
||||
type EditBodyProps = {
|
||||
payload: HttpNodeType['body']
|
||||
onChange: (value: HttpNodeType['body']) => void
|
||||
}
|
||||
|
||||
type TimeoutProps = {
|
||||
payload: HttpNodeType['timeout']
|
||||
onChange: (value: HttpNodeType['timeout']) => void
|
||||
}
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../components/authorization', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { nodeId: string, payload: HttpNodeType['authorization'], onChange: (value: HttpNodeType['authorization']) => void, onHide: () => void }) => {
|
||||
mockAuthorizationModal(props)
|
||||
return <div data-testid="authorization-modal">{props.nodeId}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/curl-panel', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { nodeId: string, onHide: () => void, handleCurlImport: (node: HttpNodeType) => void }) => {
|
||||
mockCurlPanel(props)
|
||||
return <div data-testid="curl-panel">{props.nodeId}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/api-input', () => ({
|
||||
__esModule: true,
|
||||
default: (props: ApiInputProps) => {
|
||||
mockApiInput(props)
|
||||
return (
|
||||
<div>
|
||||
<div>{`${props.method}:${props.url}`}</div>
|
||||
<button type="button" onClick={() => props.onMethodChange(Method.post)}>emit-method-change</button>
|
||||
<button type="button" onClick={() => props.onUrlChange('https://changed.example.com')}>emit-url-change</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/key-value', () => ({
|
||||
__esModule: true,
|
||||
default: (props: KeyValueProps) => {
|
||||
mockKeyValue(props)
|
||||
return (
|
||||
<div>
|
||||
<div>{props.list.map(item => `${item.key}:${item.value}`).join(',')}</div>
|
||||
<button type="button" onClick={() => props.onChange([{ key: 'x-token', value: '123' }])}>
|
||||
emit-key-value-change
|
||||
</button>
|
||||
<button type="button" onClick={props.onAdd}>emit-key-value-add</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/edit-body', () => ({
|
||||
__esModule: true,
|
||||
default: (props: EditBodyProps) => {
|
||||
mockEditBody(props)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onChange({
|
||||
type: BodyType.json,
|
||||
data: [{ type: BodyPayloadValueType.text, value: '{"hello":"world"}' }],
|
||||
})}
|
||||
>
|
||||
emit-body-change
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/timeout', () => ({
|
||||
__esModule: true,
|
||||
default: (props: TimeoutProps) => {
|
||||
mockTimeout(props)
|
||||
return (
|
||||
<button type="button" onClick={() => props.onChange({ ...props.payload, connect: 9 })}>
|
||||
emit-timeout-change
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>,
|
||||
}))
|
||||
|
||||
const createData = (overrides: Partial<HttpNodeType> = {}): HttpNodeType => ({
|
||||
title: 'HTTP Request',
|
||||
desc: '',
|
||||
type: BlockEnum.HttpRequest,
|
||||
variables: [],
|
||||
method: Method.get,
|
||||
url: 'https://api.example.com',
|
||||
authorization: { type: AuthorizationType.none },
|
||||
headers: '',
|
||||
params: '',
|
||||
body: { type: BodyType.none, data: [] },
|
||||
timeout: { connect: 5, read: 10, write: 15 },
|
||||
ssl_verify: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const panelProps = {} as NodePanelProps<HttpNodeType>['panelProps']
|
||||
|
||||
describe('http/panel', () => {
|
||||
const handleMethodChange = vi.fn()
|
||||
const handleUrlChange = vi.fn()
|
||||
const setHeaders = vi.fn()
|
||||
const addHeader = vi.fn()
|
||||
const setParams = vi.fn()
|
||||
const addParam = vi.fn()
|
||||
const setBody = vi.fn()
|
||||
const showAuthorization = vi.fn()
|
||||
const hideAuthorization = vi.fn()
|
||||
const setAuthorization = vi.fn()
|
||||
const setTimeout = vi.fn()
|
||||
const showCurlPanel = vi.fn()
|
||||
const hideCurlPanel = vi.fn()
|
||||
const handleCurlImport = vi.fn()
|
||||
const handleSSLVerifyChange = vi.fn()
|
||||
|
||||
const createConfigResult = (overrides: Record<string, unknown> = {}) => ({
|
||||
readOnly: false,
|
||||
isDataReady: true,
|
||||
inputs: createData({
|
||||
authorization: { type: AuthorizationType.apiKey, config: null },
|
||||
}),
|
||||
handleMethodChange,
|
||||
handleUrlChange,
|
||||
headers: [{ key: 'accept', value: 'application/json' }],
|
||||
setHeaders,
|
||||
addHeader,
|
||||
params: [{ key: 'page', value: '1' }],
|
||||
setParams,
|
||||
addParam,
|
||||
setBody,
|
||||
isShowAuthorization: false,
|
||||
showAuthorization,
|
||||
hideAuthorization,
|
||||
setAuthorization,
|
||||
setTimeout,
|
||||
isShowCurlPanel: false,
|
||||
showCurlPanel,
|
||||
hideCurlPanel,
|
||||
handleCurlImport,
|
||||
handleSSLVerifyChange,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseConfig.mockReturnValue(createConfigResult())
|
||||
})
|
||||
|
||||
it('renders request fields, forwards child changes, and wires header operations', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="http-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('get:https://api.example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('body:string')).toBeInTheDocument()
|
||||
expect(screen.getByText('status_code:number')).toBeInTheDocument()
|
||||
expect(screen.getByText('headers:object')).toBeInTheDocument()
|
||||
expect(screen.getByText('files:Array[File]')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'emit-method-change' }))
|
||||
await user.click(screen.getByRole('button', { name: 'emit-url-change' }))
|
||||
await user.click(screen.getAllByRole('button', { name: 'emit-key-value-change' })[0]!)
|
||||
await user.click(screen.getAllByRole('button', { name: 'emit-key-value-add' })[0]!)
|
||||
await user.click(screen.getAllByRole('button', { name: 'emit-key-value-change' })[1]!)
|
||||
await user.click(screen.getAllByRole('button', { name: 'emit-key-value-add' })[1]!)
|
||||
await user.click(screen.getByRole('button', { name: 'emit-body-change' }))
|
||||
await user.click(screen.getByRole('button', { name: 'emit-timeout-change' }))
|
||||
await user.click(screen.getByText('workflow.nodes.http.authorization.authorization'))
|
||||
await user.click(screen.getByText('workflow.nodes.http.curl.title'))
|
||||
await user.click(screen.getByRole('switch'))
|
||||
|
||||
expect(handleMethodChange).toHaveBeenCalledWith(Method.post)
|
||||
expect(handleUrlChange).toHaveBeenCalledWith('https://changed.example.com')
|
||||
expect(setHeaders).toHaveBeenCalledWith([{ key: 'x-token', value: '123' }])
|
||||
expect(addHeader).toHaveBeenCalledTimes(1)
|
||||
expect(setParams).toHaveBeenCalledWith([{ key: 'x-token', value: '123' }])
|
||||
expect(addParam).toHaveBeenCalledTimes(1)
|
||||
expect(setBody).toHaveBeenCalledWith({
|
||||
type: BodyType.json,
|
||||
data: [{ type: 'text', value: '{"hello":"world"}' }],
|
||||
})
|
||||
expect(setTimeout).toHaveBeenCalledWith(expect.objectContaining({ connect: 9 }))
|
||||
expect(showAuthorization).toHaveBeenCalledTimes(1)
|
||||
expect(showCurlPanel).toHaveBeenCalledTimes(1)
|
||||
expect(handleSSLVerifyChange).toHaveBeenCalledWith(false)
|
||||
expect(mockApiInput).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: Method.get,
|
||||
url: 'https://api.example.com',
|
||||
}))
|
||||
})
|
||||
|
||||
it('returns null before the config data is ready', () => {
|
||||
mockUseConfig.mockReturnValueOnce(createConfigResult({ isDataReady: false }))
|
||||
|
||||
const { container } = render(
|
||||
<Panel
|
||||
id="http-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders auth and curl panels only when writable and toggled on', () => {
|
||||
mockUseConfig.mockReturnValueOnce(createConfigResult({
|
||||
isShowAuthorization: true,
|
||||
isShowCurlPanel: true,
|
||||
}))
|
||||
|
||||
const { rerender } = render(
|
||||
<Panel
|
||||
id="http-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('authorization-modal')).toHaveTextContent('http-node')
|
||||
expect(screen.getByTestId('curl-panel')).toHaveTextContent('http-node')
|
||||
|
||||
mockUseConfig.mockReturnValueOnce(createConfigResult({
|
||||
readOnly: true,
|
||||
isShowAuthorization: true,
|
||||
isShowCurlPanel: true,
|
||||
}))
|
||||
|
||||
rerender(
|
||||
<Panel
|
||||
id="http-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('authorization-modal')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('curl-panel')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { HttpNodeType } from '../types'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import useVarList from '../../_base/hooks/use-var-list'
|
||||
import useKeyValueList from '../hooks/use-key-value-list'
|
||||
import { APIType, AuthorizationType, BodyPayloadValueType, BodyType, Method } from '../types'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-var-list', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-key-value-list', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseNodeCrud = vi.mocked(useNodeCrud)
|
||||
const mockUseVarList = vi.mocked(useVarList)
|
||||
const mockUseKeyValueList = vi.mocked(useKeyValueList)
|
||||
const mockUseStore = vi.mocked(useStore)
|
||||
|
||||
const createPayload = (overrides: Partial<HttpNodeType> = {}): HttpNodeType => ({
|
||||
title: 'HTTP Request',
|
||||
desc: '',
|
||||
type: BlockEnum.HttpRequest,
|
||||
variables: [],
|
||||
method: Method.get,
|
||||
url: 'https://api.example.com',
|
||||
authorization: { type: AuthorizationType.none },
|
||||
headers: 'accept:application/json',
|
||||
params: 'page:1',
|
||||
body: {
|
||||
type: BodyType.json,
|
||||
data: '{"name":"alice"}',
|
||||
},
|
||||
timeout: { connect: 5, read: 10, write: 15 },
|
||||
ssl_verify: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('http/use-config', () => {
|
||||
const mockSetInputs = vi.fn()
|
||||
const mockHandleVarListChange = vi.fn()
|
||||
const mockHandleAddVariable = vi.fn()
|
||||
const headerSetList = vi.fn()
|
||||
const headerAddItem = vi.fn()
|
||||
const headerToggle = vi.fn()
|
||||
const paramSetList = vi.fn()
|
||||
const paramAddItem = vi.fn()
|
||||
const paramToggle = vi.fn()
|
||||
let currentInputs: HttpNodeType
|
||||
let headerFieldChange: ((value: string) => void) | undefined
|
||||
let paramFieldChange: ((value: string) => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentInputs = createPayload()
|
||||
headerFieldChange = undefined
|
||||
paramFieldChange = undefined
|
||||
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
|
||||
mockUseNodeCrud.mockImplementation(() => ({
|
||||
inputs: currentInputs,
|
||||
setInputs: mockSetInputs,
|
||||
}))
|
||||
mockUseVarList.mockReturnValue({
|
||||
handleVarListChange: mockHandleVarListChange,
|
||||
handleAddVariable: mockHandleAddVariable,
|
||||
} as ReturnType<typeof useVarList>)
|
||||
mockUseKeyValueList.mockImplementation((value, onChange) => {
|
||||
if (value === currentInputs.headers) {
|
||||
headerFieldChange = onChange
|
||||
return {
|
||||
list: [{ id: 'header-1', key: 'accept', value: 'application/json' }],
|
||||
setList: headerSetList,
|
||||
addItem: headerAddItem,
|
||||
isKeyValueEdit: true,
|
||||
toggleIsKeyValueEdit: headerToggle,
|
||||
}
|
||||
}
|
||||
|
||||
paramFieldChange = onChange
|
||||
return {
|
||||
list: [{ id: 'param-1', key: 'page', value: '1' }],
|
||||
setList: paramSetList,
|
||||
addItem: paramAddItem,
|
||||
isKeyValueEdit: false,
|
||||
toggleIsKeyValueEdit: paramToggle,
|
||||
}
|
||||
})
|
||||
mockUseStore.mockImplementation((selector) => {
|
||||
const state = {
|
||||
nodesDefaultConfigs: {
|
||||
[BlockEnum.HttpRequest]: createPayload({
|
||||
method: Method.post,
|
||||
url: 'https://default.example.com',
|
||||
headers: '',
|
||||
params: '',
|
||||
body: { type: BodyType.none, data: [] },
|
||||
timeout: { connect: 1, read: 2, write: 3 },
|
||||
ssl_verify: false,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
return selector(state as never)
|
||||
})
|
||||
})
|
||||
|
||||
it('stays pending when the node default config is unavailable', () => {
|
||||
mockUseStore.mockImplementation((selector) => {
|
||||
return selector({ nodesDefaultConfigs: {} } as never)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useConfig('http-node', currentInputs))
|
||||
|
||||
expect(result.current.isDataReady).toBe(false)
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hydrates defaults, normalizes body payloads, and exposes var-list and key-value helpers', async () => {
|
||||
const { result } = renderHook(() => useConfig('http-node', currentInputs))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: Method.get,
|
||||
url: 'https://api.example.com',
|
||||
body: {
|
||||
type: BodyType.json,
|
||||
data: [{
|
||||
type: BodyPayloadValueType.text,
|
||||
value: '{"name":"alice"}',
|
||||
}],
|
||||
},
|
||||
ssl_verify: true,
|
||||
}))
|
||||
})
|
||||
|
||||
expect(result.current.isDataReady).toBe(true)
|
||||
expect(result.current.readOnly).toBe(false)
|
||||
expect(result.current.handleVarListChange).toBe(mockHandleVarListChange)
|
||||
expect(result.current.handleAddVariable).toBe(mockHandleAddVariable)
|
||||
expect(result.current.headers).toEqual([{ id: 'header-1', key: 'accept', value: 'application/json' }])
|
||||
expect(result.current.setHeaders).toBe(headerSetList)
|
||||
expect(result.current.addHeader).toBe(headerAddItem)
|
||||
expect(result.current.isHeaderKeyValueEdit).toBe(true)
|
||||
expect(result.current.toggleIsHeaderKeyValueEdit).toBe(headerToggle)
|
||||
expect(result.current.params).toEqual([{ id: 'param-1', key: 'page', value: '1' }])
|
||||
expect(result.current.setParams).toBe(paramSetList)
|
||||
expect(result.current.addParam).toBe(paramAddItem)
|
||||
expect(result.current.isParamKeyValueEdit).toBe(false)
|
||||
expect(result.current.toggleIsParamKeyValueEdit).toBe(paramToggle)
|
||||
expect(result.current.filterVar({ type: VarType.string } as never)).toBe(true)
|
||||
expect(result.current.filterVar({ type: VarType.number } as never)).toBe(true)
|
||||
expect(result.current.filterVar({ type: VarType.secret } as never)).toBe(true)
|
||||
expect(result.current.filterVar({ type: VarType.file } as never)).toBe(false)
|
||||
})
|
||||
|
||||
it('initializes empty body data arrays when the payload body is missing', async () => {
|
||||
currentInputs = createPayload({
|
||||
body: {
|
||||
type: BodyType.formData,
|
||||
data: undefined as unknown as HttpNodeType['body']['data'],
|
||||
},
|
||||
})
|
||||
|
||||
renderHook(() => useConfig('http-node', currentInputs))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: {
|
||||
type: BodyType.formData,
|
||||
data: [],
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('updates request fields, authorization state, curl imports, and ssl verification', async () => {
|
||||
const { result } = renderHook(() => useConfig('http-node', currentInputs))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isDataReady).toBe(true)
|
||||
})
|
||||
|
||||
mockSetInputs.mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.handleMethodChange(Method.delete)
|
||||
result.current.handleUrlChange('https://changed.example.com')
|
||||
headerFieldChange?.('x-token:123')
|
||||
paramFieldChange?.('size:20')
|
||||
result.current.setBody({ type: BodyType.rawText, data: 'raw payload' })
|
||||
result.current.showAuthorization()
|
||||
})
|
||||
|
||||
expect(result.current.isShowAuthorization).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.hideAuthorization()
|
||||
result.current.setAuthorization({
|
||||
type: AuthorizationType.apiKey,
|
||||
config: {
|
||||
type: APIType.bearer,
|
||||
api_key: 'secret',
|
||||
},
|
||||
})
|
||||
result.current.setTimeout({ connect: 30, read: 40, write: 50 })
|
||||
result.current.showCurlPanel()
|
||||
})
|
||||
|
||||
expect(result.current.isShowCurlPanel).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.hideCurlPanel()
|
||||
result.current.handleCurlImport(createPayload({
|
||||
method: Method.patch,
|
||||
url: 'https://imported.example.com',
|
||||
headers: 'authorization:Bearer imported',
|
||||
params: 'debug:true',
|
||||
body: { type: BodyType.json, data: [{ type: BodyPayloadValueType.text, value: '{"ok":true}' }] },
|
||||
}))
|
||||
result.current.handleSSLVerifyChange(false)
|
||||
})
|
||||
|
||||
expect(result.current.isShowAuthorization).toBe(false)
|
||||
expect(result.current.isShowCurlPanel).toBe(false)
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ method: Method.delete }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://changed.example.com' }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ headers: 'x-token:123' }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ params: 'size:20' }))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: { type: BodyType.rawText, data: 'raw payload' },
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
authorization: expect.objectContaining({
|
||||
type: AuthorizationType.apiKey,
|
||||
}),
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
timeout: { connect: 30, read: 40, write: 50 },
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: Method.patch,
|
||||
url: 'https://imported.example.com',
|
||||
headers: 'authorization:Bearer imported',
|
||||
params: 'debug:true',
|
||||
body: { type: BodyType.json, data: [{ type: BodyPayloadValueType.text, value: '{"ok":true}' }] },
|
||||
}))
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ ssl_verify: false }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { HumanInputNodeType } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
import { DeliveryMethodType, UserActionButtonType } from '../types'
|
||||
|
||||
vi.mock('../../_base/components/node-handle', () => ({
|
||||
NodeSourceHandle: (props: { handleId: string }) => <div>{`handle:${props.handleId}`}</div>,
|
||||
}))
|
||||
|
||||
const createData = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNodeType => ({
|
||||
title: 'Human Input',
|
||||
desc: '',
|
||||
type: BlockEnum.HumanInput,
|
||||
delivery_methods: [{
|
||||
id: 'dm-webapp',
|
||||
type: DeliveryMethodType.WebApp,
|
||||
enabled: true,
|
||||
}, {
|
||||
id: 'dm-email',
|
||||
type: DeliveryMethodType.Email,
|
||||
enabled: true,
|
||||
}],
|
||||
form_content: 'Please review this request',
|
||||
inputs: [{
|
||||
type: InputVarType.textInput,
|
||||
output_variable_name: 'review_result',
|
||||
default: {
|
||||
selector: [],
|
||||
type: 'constant',
|
||||
value: '',
|
||||
},
|
||||
}],
|
||||
user_actions: [{
|
||||
id: 'approve',
|
||||
title: 'Approve',
|
||||
button_style: UserActionButtonType.Primary,
|
||||
}, {
|
||||
id: 'reject',
|
||||
title: 'Reject',
|
||||
button_style: UserActionButtonType.Default,
|
||||
}],
|
||||
timeout: 3,
|
||||
timeout_unit: 'day',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('human-input/node', () => {
|
||||
it('renders delivery methods, user action handles, and the timeout handle', () => {
|
||||
render(
|
||||
<Node
|
||||
id="human-input-node"
|
||||
data={createData()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('webapp')).toBeInTheDocument()
|
||||
expect(screen.getByText('email')).toBeInTheDocument()
|
||||
expect(screen.getByText('approve')).toBeInTheDocument()
|
||||
expect(screen.getByText('reject')).toBeInTheDocument()
|
||||
expect(screen.getByText('Timeout')).toBeInTheDocument()
|
||||
expect(screen.getByText('handle:approve')).toBeInTheDocument()
|
||||
expect(screen.getByText('handle:reject')).toBeInTheDocument()
|
||||
expect(screen.getByText('handle:__timeout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the timeout handle when delivery methods and actions are empty', () => {
|
||||
render(
|
||||
<Node
|
||||
id="human-input-node"
|
||||
data={createData({
|
||||
delivery_methods: [],
|
||||
user_actions: [],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('workflow.nodes.humanInput.deliveryMethod.title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Timeout')).toBeInTheDocument()
|
||||
expect(screen.getByText('handle:__timeout')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user