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 }) => ( +
+
{label}
+ {children} +
+ ), + 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 }) => ( +
+
{title}
+ {children} +
+ ), +})) + +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 }) => ( +