diff --git a/web/app/components/workflow/__tests__/block-icon.spec.tsx b/web/app/components/workflow/__tests__/block-icon.spec.tsx
new file mode 100644
index 0000000000..c3b30a67b6
--- /dev/null
+++ b/web/app/components/workflow/__tests__/block-icon.spec.tsx
@@ -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()
+
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ expect(container.querySelector('.custom-var-icon')).toBeInTheDocument()
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ expect(container.querySelector('.bg-util-colors-warning-warning-500')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/__tests__/context.spec.tsx b/web/app/components/workflow/__tests__/context.spec.tsx
new file mode 100644
index 0000000000..ccf1eaa9b1
--- /dev/null
+++ b/web/app/components/workflow/__tests__/context.spec.tsx
@@ -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 (
+
+ )
+}
+
+describe('WorkflowContextProvider', () => {
+ it('provides the workflow store to descendants and keeps the same store across rerenders', async () => {
+ const user = userEvent.setup()
+ const { rerender } = render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'closed' })).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'closed' }))
+ expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()
+
+ rerender(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'open' })).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/__tests__/index.spec.tsx b/web/app/components/workflow/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..77b61e54e7
--- /dev/null
+++ b/web/app/components/workflow/__tests__/index.spec.tsx
@@ -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 (
+
+ {`history:${store.getState().nodes.length}`}
+ {` shortcuts:${String(shortcutsEnabled)}`}
+ {` datasets:${datasetCount}`}
+ {` reactflow:${String(!!reactFlowStore)}`}
+
+ )
+}
+
+describe('WorkflowWithDefaultContext', () => {
+ it('wires the ReactFlow, workflow history, and datasets detail providers around its children', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(
+ screen.getByText('history:1 shortcuts:true datasets:0 reactflow:true'),
+ ).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/__tests__/shortcuts-name.spec.tsx b/web/app/components/workflow/__tests__/shortcuts-name.spec.tsx
new file mode 100644
index 0000000000..87efddb005
--- /dev/null
+++ b/web/app/components/workflow/__tests__/shortcuts-name.spec.tsx
@@ -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(
+ ,
+ )
+
+ 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()
+
+ expect(screen.getByText('ctrl')).toBeInTheDocument()
+ expect(screen.getByText('alt')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx b/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx
new file mode 100644
index 0000000000..931cd97c02
--- /dev/null
+++ b/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx
@@ -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 (
+
+ )
+}
+
+describe('WorkflowHistoryProvider', () => {
+ it('provides workflow history state and shortcut toggles', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+ ,
+ )
+
+ 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 }) => (
+
+ {children}
+
+ )
+
+ 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',
+ )
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx b/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx
new file mode 100644
index 0000000000..64f012fae3
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx
@@ -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()),
+ 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)
+ mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock())
+ })
+
+ it('filters tools by the active tab', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByText('workflow.tabs.noPluginsFound')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx
new file mode 100644
index 0000000000..00972f808c
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx
@@ -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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ expect(screen.getByText('workflow.tabs.noResult')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/factories.ts b/web/app/components/workflow/block-selector/__tests__/factories.ts
new file mode 100644
index 0000000000..b7d82f7cb3
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/factories.ts
@@ -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 => ({
+ 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 => ({
+ 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(),
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/featured-tools.spec.tsx b/web/app/components/workflow/block-selector/__tests__/featured-tools.spec.tsx
new file mode 100644
index 0000000000..1720a2d897
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/featured-tools.spec.tsx
@@ -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()),
+ 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)
+ })
+
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ expect(screen.getByText('workflow.tabs.noFeaturedPlugins')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx
new file mode 100644
index 0000000000..6d27560802
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx
@@ -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[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)
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/index.spec.tsx b/web/app/components/workflow/block-selector/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..735a831c10
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/index.spec.tsx
@@ -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(
+ ,
+ {
+ 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()
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx
new file mode 100644
index 0000000000..1deb6ce84c
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx
@@ -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(
+ (
+
+ )}
+ />,
+ )
+
+ 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()
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/tools.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tools.spec.tsx
new file mode 100644
index 0000000000..a800342e6e
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/tools.spec.tsx
@@ -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)
+ })
+
+ it('shows the empty state when there are no tools and no search text', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('No tools available')).toBeInTheDocument()
+ })
+
+ it('renders tree groups for built-in and custom providers', () => {
+ render(
+ ,
+ )
+
+ 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(
+
+ 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()
+ })
+})
diff --git a/web/app/components/workflow/block-selector/tool/__tests__/tool.spec.tsx b/web/app/components/workflow/block-selector/tool/__tests__/tool.spec.tsx
new file mode 100644
index 0000000000..d9fad38854
--- /dev/null
+++ b/web/app/components/workflow/block-selector/tool/__tests__/tool.spec.tsx
@@ -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)
+ })
+
+ it('expands a provider and selects an action item', async () => {
+ const user = userEvent.setup()
+ const onSelect = vi.fn()
+
+ render(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ 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',
+ }))
+ })
+})
diff --git a/web/app/components/workflow/block-selector/tool/tool-list-flat-view/__tests__/list.spec.tsx b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/__tests__/list.spec.tsx
new file mode 100644
index 0000000000..ecb5dfe0a6
--- /dev/null
+++ b/web/app/components/workflow/block-selector/tool/tool-list-flat-view/__tests__/list.spec.tsx
@@ -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)
+ })
+
+ it('assigns the first tool of each letter to the shared refs and renders the index bar', () => {
+ const toolRefs = {
+ current: {} as Record,
+ }
+
+ render(
+ ),
+ createToolProvider({
+ id: 'provider-b',
+ label: { en_US: 'B Provider', zh_Hans: 'B Provider' },
+ letter: 'B',
+ } as ReturnType),
+ ]}
+ isShowLetterIndex
+ indexBar={}
+ 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()
+ })
+})
diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/item.spec.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/item.spec.tsx
new file mode 100644
index 0000000000..027ad7c11c
--- /dev/null
+++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/item.spec.tsx
@@ -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)
+ })
+
+ it('renders the group heading and its provider list', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('My Group')).toBeInTheDocument()
+ expect(screen.getByText('Provider Alpha')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/list.spec.tsx b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/list.spec.tsx
new file mode 100644
index 0000000000..7b3c083e85
--- /dev/null
+++ b/web/app/components/workflow/block-selector/tool/tool-list-tree-view/__tests__/list.spec.tsx
@@ -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)
+ })
+
+ it('translates built-in special group names and renders the nested providers', () => {
+ render(
+
,
+ )
+
+ 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()
+ })
+})
diff --git a/web/app/components/workflow/datasets-detail-store/__tests__/store.spec.tsx b/web/app/components/workflow/datasets-detail-store/__tests__/store.spec.tsx
new file mode 100644
index 0000000000..a031c6370e
--- /dev/null
+++ b/web/app/components/workflow/datasets-detail-store/__tests__/store.spec.tsx
@@ -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 }) => (
+
+ {children}
+
+ )
+
+ 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',
+ )
+ })
+})
diff --git a/web/app/components/workflow/hooks-store/__tests__/store.spec.tsx b/web/app/components/workflow/hooks-store/__tests__/store.spec.tsx
new file mode 100644
index 0000000000..131290b834
--- /dev/null
+++ b/web/app/components/workflow/hooks-store/__tests__/store.spec.tsx
@@ -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 }) => (
+
+ {children}
+
+ )
+
+ 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',
+ )
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-DSL.spec.ts b/web/app/components/workflow/hooks/__tests__/use-DSL.spec.ts
new file mode 100644
index 0000000000..f10777ae69
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-DSL.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions-without-sync.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions-without-sync.spec.ts
new file mode 100644
index 0000000000..b38aca6398
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions-without-sync.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.helpers.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.helpers.spec.ts
new file mode 100644
index 0000000000..3741bcc653
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.helpers.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts b/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts
new file mode 100644
index 0000000000..e1e26732ae
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts
@@ -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 => ({
+ 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',
+ })
+ })
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts
new file mode 100644
index 0000000000..7b2006aa77
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud-common.spec.ts
@@ -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()),
+ toNodeOutputVars: (...args: unknown[]) => mockToNodeOutputVars(...args),
+}))
+
+const createInspectVar = (overrides: Partial = {}): 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([])
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud.spec.ts b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud.spec.ts
new file mode 100644
index 0000000000..193e4307de
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-inspect-vars-crud.spec.ts
@@ -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 => ({
+ 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, '')
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts
new file mode 100644
index 0000000000..55db395f2e
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts
@@ -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 => ({
+ 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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions-without-sync.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions-without-sync.spec.ts
new file mode 100644
index 0000000000..1a2ebe9385
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions-without-sync.spec.ts
@@ -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)
+ })
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts
new file mode 100644
index 0000000000..35a309902e
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts
@@ -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()),
+ 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()
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-meta-data.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-nodes-meta-data.spec.tsx
new file mode 100644
index 0000000000..9dffa46cb2
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-nodes-meta-data.spec.tsx
@@ -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 }>)
+const customToolsState = vi.hoisted(() => [] as Array<{ id: string, author: string, description: Record }>)
+const workflowToolsState = vi.hoisted(() => [] as Array<{ id: string, author: string, description: Record }>)
+
+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 => ({
+ 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',
+ }))
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-set-workflow-vars-with-value.spec.ts b/web/app/components/workflow/hooks/__tests__/use-set-workflow-vars-with-value.spec.ts
new file mode 100644
index 0000000000..c0d693cf24
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-set-workflow-vars-with-value.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-shortcuts.spec.ts b/web/app/components/workflow/hooks/__tests__/use-shortcuts.spec.ts
new file mode 100644
index 0000000000..b3c63ff519
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-shortcuts.spec.ts
@@ -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(() => [])
+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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts b/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts
deleted file mode 100644
index 2d40028226..0000000000
--- a/web/app/components/workflow/hooks/__tests__/use-without-sync-hooks.spec.ts
+++ /dev/null
@@ -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)
- })
- })
- })
-})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-canvas-maximize.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-canvas-maximize.spec.ts
new file mode 100644
index 0000000000..f4cde1e72a
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-canvas-maximize.spec.ts
@@ -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()
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx
new file mode 100644
index 0000000000..54917d009c
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx
@@ -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: unknown>(fn: T) => fn,
+}))
+
+vi.mock('reactflow', async () => {
+ const actual = await vi.importActual('reactflow')
+ return {
+ ...actual,
+ useStoreApi: () => ({
+ getState: () => ({
+ getNodes: () => reactFlowState.nodes,
+ edges: reactFlowState.edges,
+ }),
+ }),
+ }
+})
+
+vi.mock('react-i18next', async () => {
+ const actual = await vi.importActual('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()
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-organize.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-organize.spec.tsx
new file mode 100644
index 0000000000..424ad96630
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-organize.spec.tsx
@@ -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[],
+ 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()),
+ 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()
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-panel-interactions.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-panel-interactions.spec.tsx
new file mode 100644
index 0000000000..9ff61f70f9
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-panel-interactions.spec.tsx
@@ -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()
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-refresh-draft.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-refresh-draft.spec.ts
new file mode 100644
index 0000000000..83c8a4199b
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-refresh-draft.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-store-only.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-store-only.spec.ts
deleted file mode 100644
index 2085e5ab47..0000000000
--- a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-store-only.spec.ts
+++ /dev/null
@@ -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)
- })
-})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts
deleted file mode 100644
index 1c8a0764d1..0000000000
--- a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-store.spec.ts
+++ /dev/null
@@ -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>(
- useHook: () => T,
- options?: {
- nodes?: ReturnType
- edges?: ReturnType
- initialStoreState?: Record
- },
-) {
- 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)
- })
- })
-})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts
deleted file mode 100644
index 73b16acf2e..0000000000
--- a/web/app/components/workflow/hooks/__tests__/use-workflow-run-event-with-viewport.spec.ts
+++ /dev/null
@@ -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>(
- useHook: () => T,
- options?: {
- nodes?: ReturnType
- edges?: ReturnType
- initialStoreState?: Record
- },
-) {
- 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)
- })
-})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-run.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-run.spec.ts
new file mode 100644
index 0000000000..ff8c64656e
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-run.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx
new file mode 100644
index 0000000000..4e9f4c9b45
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-search.spec.tsx
@@ -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[],
+ }),
+ useAllCustomTools: () => ({ data: [] }),
+ useAllWorkflowTools: () => ({ data: [] }),
+ useAllMCPTools: () => ({ data: [] }),
+}))
+
+const createNode = (overrides: Partial = {}): 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()
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-start-run.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-start-run.spec.tsx
new file mode 100644
index 0000000000..fdde912285
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-start-run.spec.tsx
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-update.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-update.spec.tsx
new file mode 100644
index 0000000000..8bd2a1c4f3
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-update.spec.tsx
@@ -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()),
+ 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 })
+ })
+})
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-zoom.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-zoom.spec.ts
new file mode 100644
index 0000000000..83bc1b27ad
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-zoom.spec.ts
@@ -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()
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/test-helpers.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/test-helpers.ts
new file mode 100644
index 0000000000..8c2ed18f19
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/test-helpers.ts
@@ -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>(
+ useHook: () => T,
+ options?: {
+ nodes?: ReturnType
+ edges?: ReturnType
+ initialStoreState?: Record
+ },
+) {
+ const { nodes = createRunNodes(), edges = createRunEdges(), initialStoreState } = options ?? {}
+
+ return renderWorkflowFlowHook(() => ({
+ ...useHook(),
+ nodes: useNodes(),
+ edges: useEdges(),
+ }), {
+ nodes,
+ edges,
+ reactFlowProps: { fitView: false },
+ initialStoreState,
+ })
+}
+
+export function renderViewportHook>(
+ useHook: () => T,
+ options?: {
+ nodes?: ReturnType
+ edges?: ReturnType
+ initialStoreState?: Record
+ },
+) {
+ 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 => ({
+ task_id: 'task-2',
+ data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
+ ...overrides,
+} as WorkflowStartedResponse)
+
+export const createNodeFinishedResponse = (overrides: Partial = {}): NodeFinishedResponse => ({
+ data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
+ ...overrides,
+} as NodeFinishedResponse)
+
+export const createIterationNextResponse = (overrides: Partial = {}): IterationNextResponse => ({
+ data: { node_id: 'n1' },
+ ...overrides,
+} as IterationNextResponse)
+
+export const createIterationFinishedResponse = (overrides: Partial = {}): IterationFinishedResponse => ({
+ data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
+ ...overrides,
+} as IterationFinishedResponse)
+
+export const createLoopNextResponse = (overrides: Partial = {}): LoopNextResponse => ({
+ data: { node_id: 'n1', index: 5 },
+ ...overrides,
+} as LoopNextResponse)
+
+export const createLoopFinishedResponse = (overrides: Partial = {}): LoopFinishedResponse => ({
+ data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
+ ...overrides,
+} as LoopFinishedResponse)
+
+export const createNodeStartedResponse = (overrides: Partial = {}): NodeStartedResponse => ({
+ data: { node_id: 'n1' },
+ ...overrides,
+} as NodeStartedResponse)
+
+export const pausedRunningData = (): WorkflowRunningData['result'] => ({ status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'])
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-agent-log.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-agent-log.spec.ts
new file mode 100644
index 0000000000..cabfc0f6d1
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-agent-log.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-failed.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-failed.spec.ts
new file mode 100644
index 0000000000..53ee281f7e
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-failed.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-finished.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-finished.spec.ts
new file mode 100644
index 0000000000..910b64ed18
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-finished.spec.ts
@@ -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()
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-finished.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-finished.spec.ts
new file mode 100644
index 0000000000..efcdc15d88
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-finished.spec.ts
@@ -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')
+ })
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-form-filled.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-form-filled.spec.ts
new file mode 100644
index 0000000000..aa8e89327b
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-form-filled.spec.ts
@@ -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()
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-form-timeout.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-form-timeout.spec.ts
new file mode 100644
index 0000000000..e528b49846
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-form-timeout.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-required.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-required.spec.ts
new file mode 100644
index 0000000000..23fdf8a3c3
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-required.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-finished.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-finished.spec.ts
new file mode 100644
index 0000000000..87617f0835
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-finished.spec.ts
@@ -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)
+ })
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-next.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-next.spec.ts
new file mode 100644
index 0000000000..ac5f2f02ea
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-next.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-started.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-started.spec.ts
new file mode 100644
index 0000000000..ccff1b288b
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-iteration-started.spec.ts
@@ -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)
+ })
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-finished.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-finished.spec.ts
new file mode 100644
index 0000000000..7acd9897ed
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-finished.spec.ts
@@ -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)
+ })
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-next.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-next.spec.ts
new file mode 100644
index 0000000000..5baa44c983
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-next.spec.ts
@@ -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)
+ })
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-started.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-started.spec.ts
new file mode 100644
index 0000000000..b0e8bf2cc5
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-loop-started.spec.ts
@@ -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)
+ })
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-retry.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-retry.spec.ts
new file mode 100644
index 0000000000..b3c6b814b1
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-retry.spec.ts
@@ -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)
+ })
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-started.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-started.spec.ts
new file mode 100644
index 0000000000..a8a52e0a84
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-started.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-paused.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-paused.spec.ts
new file mode 100644
index 0000000000..9cfb8f62d9
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-paused.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts
new file mode 100644
index 0000000000..fb8ea51638
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-started.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-started.spec.ts
new file mode 100644
index 0000000000..4fd49c9c6a
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-started.spec.ts
@@ -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()
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-text-chunk.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-text-chunk.spec.ts
new file mode 100644
index 0000000000..fcf36fe596
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-text-chunk.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-text-replace.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-text-replace.spec.ts
new file mode 100644
index 0000000000..f9c1dcb256
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-text-replace.spec.ts
@@ -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')
+ })
+})
diff --git a/web/app/components/workflow/nodes/agent/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/agent/__tests__/node.spec.tsx
new file mode 100644
index 0000000000..025c9bd84c
--- /dev/null
+++ b/web/app/components/workflow/nodes/agent/__tests__/node.spec.tsx
@@ -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 {props.provider ? `${props.param}:${props.provider}/${props.model}` : `${props.param}:empty-model`}
+ },
+}))
+
+vi.mock('../components/tool-icon', () => ({
+ ToolIcon: (props: { providerName: string }) => {
+ mockToolIcon(props)
+ return {`tool:${props.providerName}`}
+ },
+}))
+
+vi.mock('../../_base/components/group', () => ({
+ Group: ({ label, children }: { label: ReactNode, children: ReactNode }) => (
+
+ ),
+ GroupLabel: ({ className, children }: { className?: string, children: ReactNode }) => {children}
,
+}))
+
+vi.mock('../../_base/components/setting-item', () => ({
+ SettingItem: ({
+ label,
+ status,
+ tooltip,
+ children,
+ }: {
+ label: ReactNode
+ status?: string
+ tooltip?: string
+ children?: ReactNode
+ }) => (
+
+ {`${label}:${status || 'normal'}:${tooltip || ''}`}
+ {children}
+
+ ),
+}))
+
+const createStrategyParam = (overrides: Partial = {}): 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 => ({
+ 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 => ({
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ expect(mockModelBar).not.toHaveBeenCalled()
+ expect(mockToolIcon).not.toHaveBeenCalled()
+ expect(screen.queryByText('optionalModel:empty-model')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/nodes/agent/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent/__tests__/panel.spec.tsx
new file mode 100644
index 0000000000..15001b4757
--- /dev/null
+++ b/web/app/components/workflow/nodes/agent/__tests__/panel.spec.tsx
@@ -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
+ 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) => void
+ }) => {
+ mockAgentStrategy(props)
+ return (
+
+
+
+
+ )
+ },
+}))
+
+vi.mock('../../_base/components/memory-config', () => ({
+ __esModule: true,
+ default: (props: {
+ readonly?: boolean
+ config: { data?: AgentNodeType['memory'] }
+ onChange: (value?: AgentNodeType['memory']) => void
+ }) => {
+ mockMemoryConfig(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('../../_base/components/output-vars', () => ({
+ __esModule: true,
+ default: ({ children }: { children: ReactNode }) => {children}
,
+ VarItem: ({ name, type, description }: { name: string, type: string, description?: string }) => (
+ {`${name}:${type}:${description || ''}`}
+ ),
+}))
+
+const createStrategyParam = (overrides: Partial = {}): 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 => ({
+ 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 => ({
+ 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['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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ expect(screen.queryByRole('button', { name: 'change-memory' })).not.toBeInTheDocument()
+ expect(mockMemoryConfig).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/nodes/agent/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/agent/__tests__/use-config.spec.ts
new file mode 100644
index 0000000000..9e09ab6d78
--- /dev/null
+++ b/web/app/components/workflow/nodes/agent/__tests__/use-config.spec.ts
@@ -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 => ({
+ 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 => ({
+ 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'],
+ },
+ })],
+ }),
+ }),
+ }))
+ })
+ })
+})
diff --git a/web/app/components/workflow/nodes/agent/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/agent/__tests__/use-single-run-form-params.spec.ts
new file mode 100644
index 0000000000..33075e685f
--- /dev/null
+++ b/web/app/components/workflow/nodes/agent/__tests__/use-single-run-form-params.spec.ts
@@ -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('../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 => ({
+ 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)
+ 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)
+ mockFormatTracing.mockReturnValue([{
+ id: 'agent-node',
+ status: 'succeeded',
+ }] as unknown as ReturnType)
+ })
+
+ 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([])
+ })
+})
diff --git a/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx b/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx
new file mode 100644
index 0000000000..d85f54ed19
--- /dev/null
+++ b/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx
@@ -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()
+
+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[]
+ }) => (
+
+ {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
+ :
+ {modelList.length}
+
+ ),
+}))
+
+vi.mock('@/app/components/header/indicator', () => ({
+ default: ({ color }: { color: string }) => {`indicator:${color}`}
,
+}))
+
+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()
+
+ 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()
+
+ 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()
+
+ 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()
+ })
+})
diff --git a/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx b/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx
new file mode 100644
index 0000000000..30a12bb528
--- /dev/null
+++ b/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx
@@ -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
+ }) => {`app-icon:${background}:${icon}`}
,
+}))
+
+vi.mock('@/app/components/base/icons/src/vender/other', () => ({
+ Group: ({ className }: { className?: string }) => group-icon
,
+}))
+
+vi.mock('@/app/components/header/indicator', () => ({
+ default: ({ color }: { color: string }) => {`indicator:${color}`}
,
+}))
+
+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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ expect(screen.getByText('group-icon')).toBeInTheDocument()
+ expect(screen.queryByText(/indicator:/)).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/nodes/answer/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/answer/__tests__/panel.spec.tsx
new file mode 100644
index 0000000000..b5fbdf163f
--- /dev/null
+++ b/web/app/components/workflow/nodes/answer/__tests__/panel.spec.tsx
@@ -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 (
+
+ )
+ },
+}))
+
+const createData = (overrides: Partial = {}): 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()
+
+ 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()
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.answer.answer:Initial answer' }))
+
+ expect(handleAnswerChange).toHaveBeenCalledWith('Updated answer')
+ })
+})
diff --git a/web/app/components/workflow/nodes/answer/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/answer/__tests__/use-config.spec.ts
new file mode 100644
index 0000000000..106355e8c5
--- /dev/null
+++ b/web/app/components/workflow/nodes/answer/__tests__/use-config.spec.ts
@@ -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 => ({
+ 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)
+ })
+})
diff --git a/web/app/components/workflow/nodes/assigner/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/assigner/__tests__/node.spec.tsx
new file mode 100644
index 0000000000..a1fd87d386
--- /dev/null
+++ b/web/app/components/workflow/nodes/assigner/__tests__/node.spec.tsx
@@ -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('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
+ }) => (
+
+ {`${nodeTitle}:${nodeType}:${variables.join('.')}`}
+ {rightSlot}
+
+ ),
+}))
+
+const mockUseNodes = vi.mocked(useNodes)
+
+const createOperation = (overrides: Partial = {}): AssignerNodeOperation => ({
+ variable_selector: ['node-1', 'count'],
+ input_type: AssignerNodeInputType.variable,
+ operation: WriteMode.overwrite,
+ value: ['node-2', 'result'],
+ ...overrides,
+})
+
+const createData = (overrides: Partial = {}): 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)
+ })
+
+ it('renders the empty-state hint when no assignable variable is configured', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow.nodes.assigner.varNotSet')).toBeInTheDocument()
+ })
+
+ it('renders both version 2 and legacy previews with resolved node labels', () => {
+ const { container, rerender } = render(
+ ,
+ )
+
+ expect(screen.getByText('Answer:answer:node-1.count')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.assigner.operations.over-write')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByText('Start:start:sys.query')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.assigner.operations.append')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('skips empty v2 operations and resolves system variables through the start node', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Start:start:sys.query')).toBeInTheDocument()
+ expect(screen.queryByText('undefined:undefined:')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/nodes/assigner/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/assigner/__tests__/panel.spec.tsx
new file mode 100644
index 0000000000..c70c84beab
--- /dev/null
+++ b/web/app/components/workflow/nodes/assigner/__tests__/panel.spec.tsx
@@ -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 => ({
+ 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 (
+
+
{props.list.map(item => item.variable_selector.join('.')).join(',')}
+
+
+ )
+ },
+}))
+
+const createData = (overrides: Partial = {}): 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(
+ ,
+ )
+
+ 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'] }),
+ ])
+ })
+})
diff --git a/web/app/components/workflow/nodes/assigner/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/assigner/__tests__/use-single-run-form-params.spec.ts
new file mode 100644
index 0000000000..0551d1fd30
--- /dev/null
+++ b/web/app/components/workflow/nodes/assigner/__tests__/use-single-run-form-params.spec.ts
@@ -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 => ({
+ variable_selector: ['node-1', 'target'],
+ input_type: AssignerNodeInputType.variable,
+ operation: WriteMode.overwrite,
+ value: ['node-2', 'result'],
+ ...overrides,
+})
+
+const createData = (overrides: Partial = {}): 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)
+ })
+
+ 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'],
+ ])
+ })
+})
diff --git a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx
new file mode 100644
index 0000000000..63813c8a46
--- /dev/null
+++ b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx
@@ -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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
+
+ expect(screen.queryByText('workflow.nodes.assigner.operations.title')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/branches.spec.tsx b/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/branches.spec.tsx
new file mode 100644
index 0000000000..a9b5a304f4
--- /dev/null
+++ b/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/branches.spec.tsx
@@ -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
+ }) => (
+
+
+
+
+ ),
+}))
+
+vi.mock('../../operation-selector', () => ({
+ __esModule: true,
+ default: ({
+ onSelect,
+ }: {
+ onSelect: (item: { value: string }) => void
+ }) => (
+
+
+
+
+ ),
+}))
+
+const createOperation = (
+ overrides: Partial['list'][number]> = {},
+): ComponentProps['list'][number] => ({
+ variable_selector: ['node-a', 'flag'],
+ input_type: AssignerNodeInputType.variable,
+ operation: WriteMode.overwrite,
+ value: ['node-a', 'answer'],
+ ...overrides,
+})
+
+const renderVarList = (props: Partial> = {}) => {
+ const handleChange = vi.fn()
+ const handleOpen = vi.fn()
+
+ const result = render(
+ 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(
+ 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)
+ })
+})
diff --git a/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..f7408ab814
--- /dev/null
+++ b/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/index.spec.tsx
@@ -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['list'][number]> = {}) => ({
+ variable_selector: ['node-a', 'flag'],
+ input_type: AssignerNodeInputType.variable,
+ operation: WriteMode.overwrite,
+ value: ['node-a', 'answer'],
+ ...overrides,
+})
+
+const renderVarList = (props: Partial> = {}) => {
+ const handleChange = vi.fn()
+ const handleOpen = vi.fn()
+
+ const result = renderWorkflowFlowComponent(
+ 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(
+ 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([])
+ })
+})
diff --git a/web/app/components/workflow/nodes/code/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/code/__tests__/node.spec.tsx
new file mode 100644
index 0000000000..a8648324ed
--- /dev/null
+++ b/web/app/components/workflow/nodes/code/__tests__/node.spec.tsx
@@ -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 => ({
+ 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(
+ ,
+ )
+
+ expect(container.firstChild).toBeEmptyDOMElement()
+ })
+})
diff --git a/web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx
new file mode 100644
index 0000000000..72d640651d
--- /dev/null
+++ b/web/app/components/workflow/nodes/code/__tests__/panel.spec.tsx
@@ -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 (
+
+
{props.readOnly ? 'editor:readonly' : 'editor:editable'}
+
{props.language}
+
{props.title}
+
+
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/selector', () => ({
+ __esModule: true,
+ default: (props: {
+ value: CodeLanguage
+ onChange: (value: CodeLanguage) => void
+ }) => (
+
+ ),
+}))
+
+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 (
+
+
{props.readonly ? 'var-list:readonly' : 'var-list:editable'}
+
+
+ )
+ },
+}))
+
+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 (
+
+
{props.readonly ? 'output-list:readonly' : 'output-list:editable'}
+
+
+
+ )
+ },
+}))
+
+vi.mock('../../_base/components/remove-effect-var-confirm', () => ({
+ __esModule: true,
+ default: (props: {
+ isShow: boolean
+ onCancel: () => void
+ onConfirm: () => void
+ }) => {
+ mockRemoveEffectVarConfirm(props)
+ return props.isShow
+ ? (
+
+
+
+
+ )
+ : null
+ },
+}))
+
+const createData = (overrides: Partial = {}): 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 => ({
+ 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 = {
+ id: 'code-node',
+ data,
+ panelProps: {
+ getInputVars: vi.fn(() => []),
+ toVarInputs: vi.fn(() => []),
+ runInputData: {},
+ runInputDataRef: { current: {} },
+ setRunInputData: vi.fn(),
+ runResult: null,
+ },
+ }
+
+ return render()
+}
+
+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()
+ })
+})
diff --git a/web/app/components/workflow/nodes/code/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/code/__tests__/use-config.spec.ts
new file mode 100644
index 0000000000..b02ff8a4fc
--- /dev/null
+++ b/web/app/components/workflow/nodes/code/__tests__/use-config.spec.ts
@@ -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 => ({
+ 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
+ }
+ 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)
+ mockUseOutputVarList.mockReturnValue({
+ handleVarsChange: mockHandleVarsChange,
+ handleAddVariable: mockHandleAddOutputVariable,
+ handleRemoveVariable: mockHandleRemoveVariable,
+ isShowRemoveVarConfirm: false,
+ hideRemoveVarConfirm: mockHideRemoveVarConfirm,
+ onRemoveVarConfirm: mockOnRemoveVarConfirm,
+ } as ReturnType)
+ 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,
+ }))
+ })
+})
diff --git a/web/app/components/workflow/nodes/code/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/code/__tests__/use-single-run-form-params.spec.ts
new file mode 100644
index 0000000000..39e9d8139a
--- /dev/null
+++ b/web/app/components/workflow/nodes/code/__tests__/use-single-run-form-params.spec.ts
@@ -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 => ({
+ 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)
+ })
+
+ 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()
+ })
+})
diff --git a/web/app/components/workflow/nodes/data-source/__tests__/before-run-form.spec.tsx b/web/app/components/workflow/nodes/data-source/__tests__/before-run-form.spec.tsx
new file mode 100644
index 0000000000..c12ec212bf
--- /dev/null
+++ b/web/app/components/workflow/nodes/data-source/__tests__/before-run-form.spec.tsx
@@ -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[] }) => {allowedExtensions.join(',')}
,
+}))
+
+vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents', () => ({
+ __esModule: true,
+ default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl', () => ({
+ __esModule: true,
+ default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive', () => ({
+ __esModule: true,
+ default: ({ onCredentialChange }: { onCredentialChange: (credentialId: string) => void }) => (
+
+ ),
+}))
+
+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 }) => (
+
+
{nodeName}
+
+ {children}
+
+ ),
+}))
+
+vi.mock('../hooks/use-before-run-form', () => ({
+ __esModule: true,
+ default: vi.fn(),
+}))
+
+const mockUseBeforeRunForm = vi.mocked(useBeforeRunForm)
+
+const createData = (overrides: Partial = {}): 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 => ({
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ await user.click(screen.getByRole('button', { name: 'online-drive' }))
+
+ expect(mockClearOnlineDriveData).toHaveBeenCalled()
+ expect(mockSetCurrentCredentialId).toHaveBeenCalledWith('credential-drive')
+ })
+})
diff --git a/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx
new file mode 100644
index 0000000000..8160da6502
--- /dev/null
+++ b/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx
@@ -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
+ }) => (
+
+ ),
+}))
+
+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 }) => {children}
,
+ VarItem: ({ name, type }: { name: string, type: string }) => {`${name}:${type}`}
,
+}))
+
+vi.mock('../../_base/components/variable/object-child-tree-panel/show', () => ({
+ __esModule: true,
+ default: ({ payload }: { payload: { name: string } }) => {payload.name}
,
+}))
+
+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 }) => (
+
+
+
+
+ )),
+}))
+
+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 => ({
+ 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['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)
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ 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()
+ })
+})
diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx
new file mode 100644
index 0000000000..09172dd673
--- /dev/null
+++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx
@@ -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>
+ websitePages: Array>
+ 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('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 => ({
+ 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 => ({
+ 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)
+
+ mockUseNodeDataUpdateHook.mockReturnValue({
+ handleNodeDataUpdate: mockHandleNodeDataUpdate,
+ handleNodeDataUpdateWithSyncDraft: vi.fn(),
+ } as ReturnType)
+ mockUseNodesSyncDraftHook.mockReturnValue({
+ handleSyncWorkflowDraft: (...args: unknown[]) => {
+ mockHandleSyncWorkflowDraft(...args)
+ const callbacks = args[2] as { onSuccess?: () => void } | undefined
+ callbacks?.onSuccess?.()
+ },
+ } as ReturnType)
+ mockUseDatasourceSingleRunHook.mockReturnValue({
+ mutateAsync: (...args: unknown[]) => mockMutateAsync(...args),
+ isPending: false,
+ } as ReturnType)
+ 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),
+ })
+ 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,
+ }),
+ }))
+ })
+})
diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx
new file mode 100644
index 0000000000..b4e79b3334
--- /dev/null
+++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx
@@ -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>
+ websitePages: Array>
+ 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('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 => ({
+ 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 => ({
+ 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)
+
+ mockUseNodeDataUpdateHook.mockReturnValue({
+ handleNodeDataUpdate: mockHandleNodeDataUpdate,
+ handleNodeDataUpdateWithSyncDraft: vi.fn(),
+ } as ReturnType)
+ mockUseNodesSyncDraftHook.mockReturnValue({
+ handleSyncWorkflowDraft: (...args: unknown[]) => {
+ mockHandleSyncWorkflowDraft(...args)
+ const callbacks = args[2] as { onSuccess?: () => void } | undefined
+ callbacks?.onSuccess?.()
+ },
+ } as ReturnType)
+ mockUseDatasourceSingleRunHook.mockReturnValue({
+ mutateAsync: (...args: unknown[]) => mockMutateAsync(...args),
+ isPending: false,
+ } as ReturnType)
+ 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,
+ }),
+ })
+ })
+})
diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/document-extractor/__tests__/node.spec.tsx
new file mode 100644
index 0000000000..2044d7e6b9
--- /dev/null
+++ b/web/app/components/workflow/nodes/document-extractor/__tests__/node.spec.tsx
@@ -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('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
+ }) => {`${nodeTitle}:${nodeType}:${variables.join('.')}`}
,
+}))
+
+const mockUseNodes = vi.mocked(useNodes)
+
+const createData = (overrides: Partial = {}): 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)
+ })
+
+ it('renders nothing when no input variable is configured', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('renders the selected input variable label', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow.nodes.docExtractor.inputVar')).toBeInTheDocument()
+ expect(screen.getByText('Input Files:start:node-1.files')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/document-extractor/__tests__/panel.spec.tsx
new file mode 100644
index 0000000000..06512f94c6
--- /dev/null
+++ b/web/app/components/workflow/nodes/document-extractor/__tests__/panel.spec.tsx
@@ -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 }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
+ __esModule: true,
+ default: ({ children }: { children: ReactNode }) => {children}
,
+ VarItem: ({ name, type }: { name: string, type: string }) => {`${name}:${type}`}
,
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
+ __esModule: true,
+ default: () => split
,
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
+ __esModule: true,
+ default: ({
+ onChange,
+ }: {
+ onChange: (value: string[]) => void
+ }) => ,
+}))
+
+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 => ({
+ title: 'Document Extractor',
+ desc: '',
+ type: BlockEnum.DocExtractor,
+ variable_selector: ['node-1', 'files'],
+ is_array_file: false,
+ ...overrides,
+})
+
+const createConfigResult = (overrides: Partial> = {}): ReturnType => ({
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf、 markdown、 docx"}')).toBeInTheDocument()
+ expect(screen.getByText('text:array[string]')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/document-extractor/__tests__/use-config.spec.ts
new file mode 100644
index 0000000000..d988b2751d
--- /dev/null
+++ b/web/app/components/workflow/nodes/document-extractor/__tests__/use-config.spec.ts
@@ -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('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 => ({
+ 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)
+ mockUseWorkflowVariables.mockReturnValue({
+ getCurrentVariableType,
+ } as unknown as ReturnType)
+ mockUseStoreApi.mockReturnValue({
+ getState: () => ({
+ getNodes: () => [
+ { id: 'doc-node', parentId: 'loop-1', data: { type: BlockEnum.DocExtractor } },
+ { id: 'loop-1', data: { type: BlockEnum.Loop } },
+ ],
+ }),
+ } as ReturnType)
+ mockUseNodeCrud.mockReturnValue({
+ inputs: createData(),
+ setInputs,
+ } as ReturnType)
+ })
+
+ 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)
+ })
+})
diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/document-extractor/__tests__/use-single-run-form-params.spec.ts
new file mode 100644
index 0000000000..935118f26e
--- /dev/null
+++ b/web/app/components/workflow/nodes/document-extractor/__tests__/use-single-run-form-params.spec.ts
@@ -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 => ({
+ 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'])
+ })
+})
diff --git a/web/app/components/workflow/nodes/end/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/end/__tests__/panel.spec.tsx
new file mode 100644
index 0000000000..b4218e338b
--- /dev/null
+++ b/web/app/components/workflow/nodes/end/__tests__/panel.spec.tsx
@@ -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 => ({
+ 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()
+
+ 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()
+
+ expect(screen.queryByTestId('add-button')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/nodes/end/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/end/__tests__/use-config.spec.ts
new file mode 100644
index 0000000000..8d0cbff547
--- /dev/null
+++ b/web/app/components/workflow/nodes/end/__tests__/use-config.spec.ts
@@ -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 => ({
+ 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,
+ }))
+ })
+})
diff --git a/web/app/components/workflow/nodes/http/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/http/__tests__/node.spec.tsx
new file mode 100644
index 0000000000..428aabd99e
--- /dev/null
+++ b/web/app/components/workflow/nodes/http/__tests__/node.spec.tsx
@@ -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 {props.value}
+ },
+}))
+
+const createData = (overrides: Partial = {}): 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ expect(container).toBeEmptyDOMElement()
+ })
+})
diff --git a/web/app/components/workflow/nodes/http/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/http/__tests__/panel.spec.tsx
new file mode 100644
index 0000000000..e8ce5ac5c3
--- /dev/null
+++ b/web/app/components/workflow/nodes/http/__tests__/panel.spec.tsx
@@ -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 {props.nodeId}
+ },
+}))
+
+vi.mock('../components/curl-panel', () => ({
+ __esModule: true,
+ default: (props: { nodeId: string, onHide: () => void, handleCurlImport: (node: HttpNodeType) => void }) => {
+ mockCurlPanel(props)
+ return {props.nodeId}
+ },
+}))
+
+vi.mock('../components/api-input', () => ({
+ __esModule: true,
+ default: (props: ApiInputProps) => {
+ mockApiInput(props)
+ return (
+
+
{`${props.method}:${props.url}`}
+
+
+
+ )
+ },
+}))
+
+vi.mock('../components/key-value', () => ({
+ __esModule: true,
+ default: (props: KeyValueProps) => {
+ mockKeyValue(props)
+ return (
+
+
{props.list.map(item => `${item.key}:${item.value}`).join(',')}
+
+
+
+ )
+ },
+}))
+
+vi.mock('../components/edit-body', () => ({
+ __esModule: true,
+ default: (props: EditBodyProps) => {
+ mockEditBody(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('../components/timeout', () => ({
+ __esModule: true,
+ default: (props: TimeoutProps) => {
+ mockTimeout(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
+ __esModule: true,
+ default: ({ children }: { children: ReactNode }) => {children}
,
+ VarItem: ({ name, type }: { name: string, type: string }) => {`${name}:${type}`}
,
+}))
+
+const createData = (overrides: Partial = {}): 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['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 = {}) => ({
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ expect(screen.queryByTestId('authorization-modal')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('curl-panel')).not.toBeInTheDocument()
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
+ })
+})
diff --git a/web/app/components/workflow/nodes/http/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/http/__tests__/use-config.spec.ts
new file mode 100644
index 0000000000..e771122e28
--- /dev/null
+++ b/web/app/components/workflow/nodes/http/__tests__/use-config.spec.ts
@@ -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 => ({
+ 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)
+ 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 }))
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/human-input/__tests__/node.spec.tsx
new file mode 100644
index 0000000000..915f9136be
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/__tests__/node.spec.tsx
@@ -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 }) => {`handle:${props.handleId}`}
,
+}))
+
+const createData = (overrides: Partial = {}): 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ expect(screen.queryByText('workflow.nodes.humanInput.deliveryMethod.title')).not.toBeInTheDocument()
+ expect(screen.getByText('Timeout')).toBeInTheDocument()
+ expect(screen.getByText('handle:__timeout')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx
new file mode 100644
index 0000000000..937a2da61a
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx
@@ -0,0 +1,386 @@
+import type { ReactNode } from 'react'
+import type useConfig from '../hooks/use-config'
+import type { HumanInputNodeType } 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 copy from 'copy-to-clipboard'
+import { toast } from '@/app/components/base/ui/toast'
+import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
+import Panel from '../panel'
+import { DeliveryMethodType, UserActionButtonType } from '../types'
+
+const mockUseConfig = vi.hoisted(() => vi.fn())
+const mockUseStore = vi.hoisted(() => vi.fn())
+const mockUseAvailableVarList = vi.hoisted(() => vi.fn())
+const mockDeliveryMethod = vi.hoisted(() => vi.fn())
+const mockFormContent = vi.hoisted(() => vi.fn())
+const mockFormContentPreview = vi.hoisted(() => vi.fn())
+const mockTimeoutInput = vi.hoisted(() => vi.fn())
+const mockUserActionItem = vi.hoisted(() => vi.fn())
+
+vi.mock('copy-to-clipboard', () => ({
+ __esModule: true,
+ default: vi.fn(),
+}))
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ success: vi.fn(),
+ },
+}))
+
+vi.mock('@/app/components/base/tooltip', () => ({
+ __esModule: true,
+ default: () => tooltip
,
+}))
+
+vi.mock('@/app/components/base/action-button', () => ({
+ __esModule: true,
+ default: (props: {
+ children: ReactNode
+ onClick: () => void
+ }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/workflow/store', () => ({
+ useStore: (selector: (state: { nodePanelWidth: number }) => unknown) => mockUseStore(selector),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockUseAvailableVarList(...args),
+}))
+
+vi.mock('../hooks/use-config', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockUseConfig(...args),
+}))
+
+vi.mock('../components/delivery-method', () => ({
+ __esModule: true,
+ default: (props: {
+ readonly: boolean
+ onChange: (methods: HumanInputNodeType['delivery_methods']) => void
+ }) => {
+ mockDeliveryMethod(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('../components/form-content', () => ({
+ __esModule: true,
+ default: (props: {
+ readonly: boolean
+ isExpand: boolean
+ onChange: (value: string) => void
+ onFormInputsChange: (value: HumanInputNodeType['inputs']) => void
+ onFormInputItemRename: (oldName: string, newName: string) => void
+ onFormInputItemRemove: (name: string) => void
+ }) => {
+ mockFormContent(props)
+ return (
+
+
{props.readonly ? 'form-content:readonly' : `form-content:${props.isExpand ? 'expanded' : 'collapsed'}`}
+
+
+
+
+
+ )
+ },
+}))
+
+vi.mock('../components/form-content-preview', () => ({
+ __esModule: true,
+ default: (props: {
+ onClose: () => void
+ }) => {
+ mockFormContentPreview(props)
+ return (
+
+
form-preview
+
+
+ )
+ },
+}))
+
+vi.mock('../components/timeout', () => ({
+ __esModule: true,
+ default: (props: {
+ readonly: boolean
+ onChange: (value: { timeout: number, unit: 'hour' | 'day' }) => void
+ }) => {
+ mockTimeoutInput(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('../components/user-action', () => ({
+ __esModule: true,
+ default: (props: {
+ readonly: boolean
+ data: HumanInputNodeType['user_actions'][number]
+ onChange: (value: HumanInputNodeType['user_actions'][number]) => void
+ onDelete: (id: string) => void
+ }) => {
+ mockUserActionItem(props)
+ return (
+
+
{`${props.data.id}:${props.readonly ? 'readonly' : 'editable'}`}
+
+
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
+ __esModule: true,
+ default: (props: {
+ children: ReactNode
+ collapsed?: boolean
+ onCollapse?: (collapsed: boolean) => void
+ }) => (
+
+
+ {props.children}
+
+ ),
+ VarItem: ({ name, type, description }: { name: string, type: string, description: string }) => (
+ {`${name}:${type}:${description}`}
+ ),
+}))
+
+vi.mock('@remixicon/react', () => ({
+ RiAddLine: () => add-icon,
+ RiClipboardLine: () => clipboard-icon,
+ RiCollapseDiagonalLine: () => collapse-icon,
+ RiExpandDiagonalLine: () => expand-icon,
+ RiEyeLine: () => preview-icon,
+}))
+
+const mockCopy = vi.mocked(copy)
+const mockToastSuccess = vi.mocked(toast.success)
+
+const createData = (overrides: Partial = {}): HumanInputNodeType => ({
+ title: 'Human Input',
+ desc: '',
+ type: BlockEnum.HumanInput,
+ delivery_methods: [{
+ id: 'dm-webapp',
+ type: DeliveryMethodType.WebApp,
+ 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,
+ }],
+ timeout: 3,
+ timeout_unit: 'day',
+ ...overrides,
+})
+
+const createConfigResult = (overrides: Partial> = {}): ReturnType => ({
+ readOnly: false,
+ inputs: createData(),
+ handleDeliveryMethodChange: vi.fn(),
+ handleUserActionAdd: vi.fn(),
+ handleUserActionChange: vi.fn(),
+ handleUserActionDelete: vi.fn(),
+ handleTimeoutChange: vi.fn(),
+ handleFormContentChange: vi.fn(),
+ handleFormInputsChange: vi.fn(),
+ handleFormInputItemRename: vi.fn(),
+ handleFormInputItemRemove: vi.fn(),
+ editorKey: 1,
+ structuredOutputCollapsed: true,
+ setStructuredOutputCollapsed: vi.fn(),
+ ...overrides,
+})
+
+const renderPanel = (data: HumanInputNodeType = createData()) => {
+ const props: NodePanelProps = {
+ id: 'human-input-node',
+ data,
+ panelProps: {
+ getInputVars: vi.fn(() => []),
+ toVarInputs: vi.fn(() => []),
+ runInputData: {},
+ runInputDataRef: { current: {} },
+ setRunInputData: vi.fn(),
+ runResult: null,
+ },
+ }
+
+ return render()
+}
+
+describe('human-input/panel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseStore.mockImplementation(selector => selector({ nodePanelWidth: 480 }))
+ mockUseAvailableVarList.mockImplementation((_id, options?: { filterVar?: (payload: { type: VarType }) => boolean }) => ({
+ availableVars: [{
+ variable: ['start', 'email'],
+ type: VarType.string,
+ }, {
+ variable: ['start', 'files'],
+ type: VarType.file,
+ }].filter(variable => options?.filterVar ? options.filterVar({ type: variable.type } as never) : true),
+ availableNodesWithParent: [{
+ id: 'start-node',
+ data: {
+ title: 'Start',
+ type: BlockEnum.Start,
+ },
+ }],
+ }))
+ mockUseConfig.mockReturnValue(createConfigResult())
+ })
+
+ it('renders editable controls, forwards updates, and toggles preview and output sections', async () => {
+ const user = userEvent.setup()
+ const config = createConfigResult()
+ mockUseConfig.mockReturnValue(config)
+
+ const { container } = renderPanel()
+
+ expect(screen.getByRole('button', { name: 'delivery-method:editable' })).toBeInTheDocument()
+ expect(screen.getByText('form-content:collapsed')).toBeInTheDocument()
+ expect(screen.getByText('approve:editable')).toBeInTheDocument()
+ expect(screen.getByText('review_result:string:Form input value')).toBeInTheDocument()
+ expect(screen.getByText('__action_id:string:Action ID user triggered')).toBeInTheDocument()
+ expect(screen.getByText('__rendered_content:string:Rendered content')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'delivery-method:editable' }))
+ await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.formContent\.preview/ }))
+ await user.click(screen.getByRole('button', { name: 'change-form-content' }))
+ await user.click(screen.getByRole('button', { name: 'change-form-inputs' }))
+ await user.click(screen.getByRole('button', { name: 'rename-form-input' }))
+ await user.click(screen.getByRole('button', { name: 'remove-form-input' }))
+ await user.click(screen.getByRole('button', { name: 'action-button' }))
+ await user.click(screen.getByRole('button', { name: 'change-action-approve' }))
+ await user.click(screen.getByRole('button', { name: 'delete-action-approve' }))
+ await user.click(screen.getByRole('button', { name: 'timeout:editable' }))
+ await user.click(screen.getByRole('button', { name: 'toggle-output-vars' }))
+ await user.click(screen.getByRole('button', { name: 'close-preview' }))
+
+ const iconContainers = container.querySelectorAll('div.flex.size-6.cursor-pointer')
+ await user.click(iconContainers[0] as HTMLElement)
+ await user.click(iconContainers[1] as HTMLElement)
+
+ expect(config.handleDeliveryMethodChange).toHaveBeenCalledWith([{
+ id: 'dm-email',
+ type: DeliveryMethodType.Email,
+ enabled: true,
+ }])
+ expect(config.handleFormContentChange).toHaveBeenCalledWith('Updated content')
+ expect(config.handleFormInputsChange).toHaveBeenCalled()
+ expect(config.handleFormInputItemRename).toHaveBeenCalledWith('name', 'email')
+ expect(config.handleFormInputItemRemove).toHaveBeenCalledWith('name')
+ expect(config.handleUserActionAdd).toHaveBeenCalledWith({
+ id: 'action_2',
+ title: 'Button Text 2',
+ button_style: UserActionButtonType.Default,
+ })
+ expect(config.handleUserActionChange).toHaveBeenCalledWith(0, {
+ id: 'approve',
+ title: 'Approve updated',
+ button_style: UserActionButtonType.Primary,
+ })
+ expect(config.handleUserActionDelete).toHaveBeenCalledWith('approve')
+ expect(config.handleTimeoutChange).toHaveBeenCalledWith({ timeout: 8, unit: 'hour' })
+ expect(config.setStructuredOutputCollapsed).toHaveBeenCalledWith(false)
+ expect(mockCopy).toHaveBeenCalledWith('Please review this request')
+ expect(mockToastSuccess).toHaveBeenCalledWith('common.actionMsg.copySuccessfully')
+ expect(mockFormContentPreview).toHaveBeenCalled()
+ })
+
+ it('renders readonly and empty states without preview or add controls', () => {
+ mockUseConfig.mockReturnValue(createConfigResult({
+ readOnly: true,
+ inputs: createData({
+ user_actions: [],
+ }),
+ structuredOutputCollapsed: false,
+ }))
+
+ renderPanel()
+
+ expect(screen.getByRole('button', { name: 'delivery-method:readonly' })).toBeInTheDocument()
+ expect(screen.getByText('form-content:readonly')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.humanInput.userActions.emptyTip')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /workflow\.nodes\.humanInput\.formContent\.preview/ })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'action-button' })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'timeout:readonly' })).toBeInTheDocument()
+ expect(screen.queryByText('form-preview')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx
new file mode 100644
index 0000000000..cec9ffe69a
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx
@@ -0,0 +1,180 @@
+import type { EmailConfig } from '../../../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import EmailConfigureModal from '../email-configure-modal'
+
+const mockToastError = vi.hoisted(() => vi.fn())
+const mockUseAppContextSelector = vi.hoisted(() => vi.fn())
+
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: {
+ error: (message: string) => mockToastError(message),
+ },
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useSelector: (selector: (state: { userProfile: { email: string } }) => string) =>
+ mockUseAppContextSelector(selector),
+}))
+
+vi.mock('../mail-body-input', () => ({
+ default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
+