Compare commits

..

15 Commits

Author SHA1 Message Date
CodingOnStar
228b9994ac test: add unit tests for email delivery methods and recipient components, improving coverage for configuration and interaction scenarios 2026-04-10 17:33:46 +08:00
Coding On Star
9e250f3481 Merge branch 'main' into test/workflow-8 2026-04-10 16:45:41 +08:00
CodingOnStar
7fd06f134b test: add unit tests for code and human input nodes, enhancing coverage for rendering and interaction scenarios 2026-04-10 16:45:00 +08:00
CodingOnStar
7430d8c8f1 test: enhance unit tests for useKnowledgeMetadataConfig hook, adding scenarios for logical operator toggling and handling missing conditions 2026-04-10 16:19:33 +08:00
CodingOnStar
0fe8bafbe9 Merge remote-tracking branch 'origin/main' into test/workflow-8 2026-04-10 16:03:28 +08:00
CodingOnStar
b5ff502a46 test: add unit tests for knowledge retrieval hooks, enhancing coverage for dataset selection, metadata configuration, and model configuration 2026-04-10 15:59:36 +08:00
CodingOnStar
ffc61b08e8 test: add unit tests for knowledge retrieval hooks, enhancing coverage for dataset selection, metadata configuration, and model configuration 2026-04-10 13:57:41 +08:00
CodingOnStar
e38ba74b9c test: improve parameter extractor update tests by utilizing dialog context for input interactions 2026-04-10 13:35:53 +08:00
CodingOnStar
8907c6787e test: add unit tests for agent and HTTP components, enhancing coverage for configuration and rendering behaviors 2026-04-10 12:48:04 +08:00
CodingOnStar
331158e4bf test: enhance unit tests for parameter extractor components with async interactions and improved event handling 2026-04-10 10:31:19 +08:00
Coding On Star
9a44d1ec40 Merge branch 'main' into test/workflow-8 2026-04-10 10:08:21 +08:00
CodingOnStar
4d03f4334a test: add unit tests for various workflow components including edge interactions, answer panel, data source forms, document extractor, and template transform 2026-04-10 10:06:59 +08:00
CodingOnStar
fbb7642c3b test: add unit tests for workflow components including tools and inspect vars 2026-04-09 17:09:36 +08:00
CodingOnStar
756658ed71 test: add unit tests for workflow components and stores 2026-04-09 16:10:44 +08:00
CodingOnStar
ca60bb5812 test: add unit tests for workflow components and stores 2026-04-09 14:55:42 +08:00
274 changed files with 26246 additions and 2809 deletions

View File

@@ -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

View File

@@ -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.

View 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()
})
})

View 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()
})
})

View 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()
})
})

View File

@@ -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()
})
})

View File

@@ -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',
)
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})

View File

@@ -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(),
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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',
}))
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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',
)
})
})

View File

@@ -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',
)
})
})

View 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)
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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',
})
})
})
})

View File

@@ -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([])
})
})

View File

@@ -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, '')
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})
})

View File

@@ -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()
})
})

View File

@@ -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',
}))
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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 })
})
})

View File

@@ -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()
})
})

View File

@@ -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'])

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})

View File

@@ -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')
})
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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')
})
})

View 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()
})
})

View 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()
})
})

View File

@@ -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'],
},
})],
}),
}),
}))
})
})
})

View File

@@ -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([])
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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')
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})

View File

@@ -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'] }),
])
})
})

View File

@@ -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'],
])
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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([])
})
})

View File

@@ -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()
})
})

View 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()
})
})

View File

@@ -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,
}))
})
})

View File

@@ -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()
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})

View File

@@ -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,
}),
}))
})
})

View File

@@ -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,
}),
})
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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'])
})
})

View File

@@ -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()
})
})

View File

@@ -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,
}))
})
})

View File

@@ -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()
})
})

View 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')
})
})

View File

@@ -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 }))
})
})

View File

@@ -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