From fdc880bc6725b82d8beb256bfcc27f90b8fc97fc Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Mon, 23 Mar 2026 16:37:03 +0800 Subject: [PATCH] test(workflow): add unit tests for workflow components (#33910) Co-authored-by: CodingOnStar --- .../__tests__/ExternalApiSelection.spec.tsx | 2 +- .../header/nav/__tests__/index.spec.tsx | 29 +- .../__tests__/tool-picker.spec.tsx | 532 +++++++++++ .../__tests__/provider.spec.tsx | 91 ++ .../header/__tests__/header-layouts.spec.tsx | 308 +++++++ .../workflow/header/__tests__/index.spec.tsx | 106 +++ .../hooks-store/__tests__/provider.spec.tsx | 73 ++ .../workflow/nodes/__tests__/index.spec.tsx | 107 +++ .../__tests__/file-support.spec.tsx | 226 +++++ .../error-handle/__tests__/index.spec.tsx | 250 +++++ .../input-field/__tests__/index.spec.tsx | 8 + .../layout/__tests__/index.spec.tsx | 57 +- .../__tests__/index.spec.tsx | 114 +++ .../__tests__/placeholder.spec.tsx | 78 ++ .../panel-operator/__tests__/details.spec.tsx | 268 ++++++ .../__tests__/index.spec.tsx | 52 ++ .../assigned-var-reference-popup.spec.tsx | 72 ++ .../variable-label/__tests__/index.spec.tsx | 98 +- .../agent/__tests__/integration.spec.tsx | 340 +++++++ .../assigner/__tests__/integration.spec.tsx | 514 +++++++++++ .../code/__tests__/dependency-picker.spec.tsx | 39 + .../__tests__/integration.spec.tsx | 204 +++++ .../nodes/http/__tests__/integration.spec.tsx | 705 +++++++++++++++ .../if-else/__tests__/integration.spec.tsx | 430 +++++++++ .../iteration/__tests__/integration.spec.tsx | 266 ++++++ .../__tests__/integration.spec.tsx | 615 +++++++++++++ .../__tests__/integration.spec.tsx | 309 +++++++ .../nodes/llm/__tests__/panel.spec.tsx | 105 +-- .../nodes/loop/__tests__/integration.spec.tsx | 665 ++++++++++++++ .../__tests__/integration.spec.tsx | 851 ++++++++++++++++++ .../__tests__/integration.spec.tsx | 385 ++++++++ .../__tests__/integration.spec.tsx | 224 +++++ .../__tests__/input-var-list.spec.tsx | 513 +++++++++++ .../trigger-schedule/__tests__/panel.spec.tsx | 266 ++++++ .../components/__tests__/integration.spec.tsx | 151 ++++ .../__tests__/integration.spec.tsx | 537 +++++++++++ .../__tests__/human-input-form-list.spec.tsx | 162 ++++ .../workflow/panel/__tests__/index.spec.tsx | 311 +++++-- .../panel/__tests__/workflow-preview.spec.tsx | 354 ++++++++ .../__tests__/integration.spec.tsx | 176 ++++ .../workflow/panel/chat-record/user-input.tsx | 17 +- .../__tests__/index.spec.tsx | 262 ++++++ .../components/__tests__/integration.spec.tsx | 282 ++++++ .../__tests__/components.spec.tsx | 610 +++++++++++++ .../env-panel/__tests__/integration.spec.tsx | 267 ++++++ .../__tests__/index.spec.tsx | 55 ++ .../__tests__/index.spec.tsx | 68 ++ .../run/__tests__/loop-result-panel.spec.tsx | 116 +++ .../agent-log/__tests__/integration.spec.tsx | 101 +++ .../run/agent-log/agent-log-nav-more.tsx | 2 +- .../__tests__/integration.spec.tsx | 70 ++ .../__tests__/retry-result-panel.spec.tsx | 75 ++ .../simple-node/__tests__/index.spec.tsx | 138 +++ web/eslint-suppressions.json | 2 +- 54 files changed, 12469 insertions(+), 189 deletions(-) create mode 100644 web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx create mode 100644 web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx create mode 100644 web/app/components/workflow/header/__tests__/header-layouts.spec.tsx create mode 100644 web/app/components/workflow/header/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/hooks-store/__tests__/provider.spec.tsx create mode 100644 web/app/components/workflow/nodes/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/error-handle/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/support-var-input/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/assigned-var-reference-popup.spec.tsx create mode 100644 web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/code/__tests__/dependency-picker.spec.tsx create mode 100644 web/app/components/workflow/nodes/document-extractor/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/http/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/if-else/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/list-operator/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/loop/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/parameter-extractor/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/template-transform/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/nodes/variable-assigner/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/panel/__tests__/human-input-form-list.spec.tsx create mode 100644 web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx create mode 100644 web/app/components/workflow/panel/chat-record/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/panel/chat-variable-panel/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/panel/debug-and-preview/__tests__/components.spec.tsx create mode 100644 web/app/components/workflow/panel/env-panel/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/panel/global-variable-panel/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/plugin-dependency/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/run/__tests__/loop-result-panel.spec.tsx create mode 100644 web/app/components/workflow/run/agent-log/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/run/iteration-log/__tests__/integration.spec.tsx create mode 100644 web/app/components/workflow/run/retry-log/__tests__/retry-result-panel.spec.tsx create mode 100644 web/app/components/workflow/simple-node/__tests__/index.spec.tsx diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx index 97934f36e1..8d055606b8 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx @@ -35,7 +35,7 @@ vi.mock('../ExternalApiSelect', () => ({ {value} {items.length} {items.map((item: MockSelectItem) => ( - ))} diff --git a/web/app/components/header/nav/__tests__/index.spec.tsx b/web/app/components/header/nav/__tests__/index.spec.tsx index 6ee8a7a924..3dce8375b3 100644 --- a/web/app/components/header/nav/__tests__/index.spec.tsx +++ b/web/app/components/header/nav/__tests__/index.spec.tsx @@ -8,6 +8,7 @@ import { waitFor, } from '@testing-library/react' import * as React from 'react' +import { use } from 'react' import { vi } from 'vitest' import { useStore as useAppStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' @@ -23,14 +24,14 @@ vi.mock('@headlessui/react', () => { const [open, setOpen] = React.useState(false) const value = React.useMemo(() => ({ open, setOpen }), [open]) return ( - + {typeof children === 'function' ? children({ open }) : children} - + ) } const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => { - const context = React.useContext(MenuContext) + const context = use(MenuContext) const handleClick = () => { context?.setOpen(!context.open) onClick?.() @@ -43,7 +44,7 @@ vi.mock('@headlessui/react', () => { } const MenuItems = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => { - const context = React.useContext(MenuContext) + const context = use(MenuContext) if (!context?.open) return null return ( @@ -84,6 +85,26 @@ vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) +vi.mock('@/next/link', () => ({ + default: ({ + href, + children, + onClick, + ...props + }: React.AnchorHTMLAttributes & { href: string, children?: React.ReactNode }) => ( + { + event.preventDefault() + onClick?.(event) + }} + {...props} + > + {children} + + ), +})) + describe('Nav Component', () => { const mockSetAppDetail = vi.fn() const mockOnCreate = vi.fn() diff --git a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx new file mode 100644 index 0000000000..47ad2fad02 --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx @@ -0,0 +1,532 @@ +import type { ToolWithProvider } from '../../types' +import type { ToolValue } from '../types' +import type { Plugin } from '@/app/components/plugins/types' +import type { Tool } from '@/app/components/tools/types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useTags } from '@/app/components/plugins/hooks' +import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { CollectionType } from '@/app/components/tools/types' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useGetLanguage } from '@/context/i18n' +import useTheme from '@/hooks/use-theme' +import { createCustomCollection } from '@/service/tools' +import { useFeaturedToolsRecommendations } from '@/service/use-plugins' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, + useInvalidateAllBuiltInTools, + useInvalidateAllCustomTools, + useInvalidateAllMCPTools, + useInvalidateAllWorkflowTools, +} from '@/service/use-tools' +import { Theme } from '@/types/app' +import { defaultSystemFeatures } from '@/types/feature' +import ToolPicker from '../tool-picker' + +const mockNotify = vi.fn() +const mockSetSystemFeatures = vi.fn() +const mockInvalidateBuiltInTools = vi.fn() +const mockInvalidateCustomTools = vi.fn() +const mockInvalidateWorkflowTools = vi.fn() +const mockInvalidateMcpTools = vi.fn() +const mockCreateCustomCollection = vi.mocked(createCustomCollection) +const mockInstallPackageFromMarketPlace = vi.fn() +const mockCheckInstalled = vi.fn() +const mockRefreshPluginList = vi.fn() + +const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) +const mockUseGetLanguage = vi.mocked(useGetLanguage) +const mockUseTheme = vi.mocked(useTheme) +const mockUseTags = vi.mocked(useTags) +const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins) +const mockUseAllBuiltInTools = vi.mocked(useAllBuiltInTools) +const mockUseAllCustomTools = vi.mocked(useAllCustomTools) +const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools) +const mockUseAllMCPTools = vi.mocked(useAllMCPTools) +const mockUseInvalidateAllBuiltInTools = vi.mocked(useInvalidateAllBuiltInTools) +const mockUseInvalidateAllCustomTools = vi.mocked(useInvalidateAllCustomTools) +const mockUseInvalidateAllWorkflowTools = vi.mocked(useInvalidateAllWorkflowTools) +const mockUseInvalidateAllMCPTools = vi.mocked(useInvalidateAllMCPTools) +const mockUseFeaturedToolsRecommendations = vi.mocked(useFeaturedToolsRecommendations) + +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/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTags: vi.fn(), + } +}) + +vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ + useMarketplacePlugins: vi.fn(), +})) + +vi.mock('@/service/tools', () => ({ + createCustomCollection: vi.fn(), +})) + +vi.mock('@/service/use-plugins', () => ({ + useFeaturedToolsRecommendations: vi.fn(), + useDownloadPlugin: vi.fn(() => ({ + data: undefined, + isLoading: false, + })), + useInstallPackageFromMarketPlace: () => ({ + mutateAsync: mockInstallPackageFromMarketPlace, + isPending: false, + }), + usePluginDeclarationFromMarketPlace: () => ({ + data: undefined, + }), + usePluginTaskList: () => ({ + handleRefetch: vi.fn(), + }), + useUpdatePackageFromMarketPlace: () => ({ + mutateAsync: vi.fn(), + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: vi.fn(), + useAllCustomTools: vi.fn(), + useAllWorkflowTools: vi.fn(), + useAllMCPTools: vi.fn(), + useInvalidateAllBuiltInTools: vi.fn(), + useInvalidateAllCustomTools: vi.fn(), + useInvalidateAllWorkflowTools: vi.fn(), + useInvalidateAllMCPTools: vi.fn(), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: (payload: unknown) => mockNotify(payload), + }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +vi.mock('next-themes', () => ({ + useTheme: () => ({ theme: Theme.light }), +})) + +vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({ + default: ({ + onAdd, + onHide, + }: { + onAdd: (payload: { name: string }) => Promise + onHide: () => void + }) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ + default: () => mockCheckInstalled(), +})) + +vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({ + default: () => ({ + canInstall: true, + }), +})) + +vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({ + default: () => ({ + refreshPluginList: mockRefreshPluginList, + }), +})) + +vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({ + default: () => ({ + check: vi.fn().mockResolvedValue({ status: 'success' }), + stop: vi.fn(), + }), +})) + +vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ + default: ({ + onSuccess, + onClose, + }: { + onSuccess: () => void | Promise + onClose: () => void + }) => ( +
+ + +
+ ), +})) + +vi.mock('@/utils/var', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getMarketplaceUrl: () => 'https://marketplace.test/tools', + } +}) + +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: {}, +}) + +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, +}) + +const createToolValue = (overrides: Partial = {}): ToolValue => ({ + provider_name: 'provider-a', + tool_name: 'tool-a', + tool_label: 'Tool A', + ...overrides, +}) + +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' }, + brief: { en_US: 'Brief' }, + description: { en_US: 'Plugin description' }, + introduction: 'Intro', + repository: 'https://example.com', + category: PluginCategoryEnum.tool, + install_count: 0, + endpoint: { settings: [] }, + tags: [{ name: 'tag-a' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +const builtInTools = [ + createToolProvider({ + id: 'built-in-1', + name: 'built-in-provider', + label: { en_US: 'Built-in Provider', zh_Hans: 'Built-in Provider' }, + tools: [createTool('built-in-tool', 'Built-in Tool')], + }), +] + +const customTools = [ + createToolProvider({ + id: 'custom-1', + name: 'custom-provider', + label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' }, + type: CollectionType.custom, + tools: [createTool('weather-tool', 'Weather Tool')], + }), +] + +const workflowTools = [ + createToolProvider({ + id: 'workflow-1', + name: 'workflow-provider', + label: { en_US: 'Workflow Provider', zh_Hans: 'Workflow Provider' }, + type: CollectionType.workflow, + tools: [createTool('workflow-tool', 'Workflow Tool')], + }), +] + +const mcpTools = [ + createToolProvider({ + id: 'mcp-1', + name: 'mcp-provider', + label: { en_US: 'MCP Provider', zh_Hans: 'MCP Provider' }, + type: CollectionType.mcp, + tools: [createTool('mcp-tool', 'MCP Tool')], + }), +] + +const renderToolPicker = (props: Partial> = {}) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + return render( + + open-picker} + isShow={false} + onShowChange={vi.fn()} + onSelect={vi.fn()} + onSelectMultiple={vi.fn()} + selectedTools={[createToolValue()]} + {...props} + /> + , + ) +} + +describe('ToolPicker', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockUseGlobalPublicStore.mockImplementation(selector => selector({ + systemFeatures: { + ...defaultSystemFeatures, + enable_marketplace: true, + }, + setSystemFeatures: mockSetSystemFeatures, + })) + mockUseGetLanguage.mockReturnValue('en_US') + mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType) + mockUseTags.mockReturnValue({ + tags: [{ name: 'weather', label: 'Weather' }], + tagsMap: { weather: { name: 'weather', label: 'Weather' } }, + getTagLabel: (name: string) => name, + }) + mockUseMarketplacePlugins.mockReturnValue({ + 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, + } as ReturnType) + mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType) + mockUseAllCustomTools.mockReturnValue({ data: customTools } as ReturnType) + mockUseAllWorkflowTools.mockReturnValue({ data: workflowTools } as ReturnType) + mockUseAllMCPTools.mockReturnValue({ data: mcpTools } as ReturnType) + mockUseInvalidateAllBuiltInTools.mockReturnValue(mockInvalidateBuiltInTools) + mockUseInvalidateAllCustomTools.mockReturnValue(mockInvalidateCustomTools) + mockUseInvalidateAllWorkflowTools.mockReturnValue(mockInvalidateWorkflowTools) + mockUseInvalidateAllMCPTools.mockReturnValue(mockInvalidateMcpTools) + mockUseFeaturedToolsRecommendations.mockReturnValue({ + plugins: [], + isLoading: false, + } as ReturnType) + mockCreateCustomCollection.mockResolvedValue(undefined) + mockInstallPackageFromMarketPlace.mockResolvedValue({ + all_installed: true, + task_id: 'task-1', + }) + mockCheckInstalled.mockReturnValue({ + installedInfo: undefined, + isLoading: false, + error: undefined, + }) + window.localStorage.clear() + }) + + it('should request opening when the trigger is clicked unless the picker is disabled', async () => { + const user = userEvent.setup() + const onShowChange = vi.fn() + const disabledOnShowChange = vi.fn() + + renderToolPicker({ onShowChange }) + + await user.click(screen.getByRole('button', { name: 'open-picker' })) + expect(onShowChange).toHaveBeenCalledWith(true) + + renderToolPicker({ + disabled: true, + onShowChange: disabledOnShowChange, + }) + + await user.click(screen.getAllByRole('button', { name: 'open-picker' })[1]!) + expect(disabledOnShowChange).not.toHaveBeenCalled() + }) + + it('should render real search and tool lists, then forward tool selections', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const onSelectMultiple = vi.fn() + const queryPluginsWithDebounced = vi.fn() + + mockUseMarketplacePlugins.mockReturnValue({ + plugins: [], + total: 0, + resetPlugins: vi.fn(), + queryPlugins: vi.fn(), + queryPluginsWithDebounced, + cancelQueryPluginsWithDebounced: vi.fn(), + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + page: 0, + } as ReturnType) + + renderToolPicker({ + isShow: true, + scope: 'custom', + onSelect, + onSelectMultiple, + selectedTools: [], + }) + + expect(screen.queryByText('Built-in Provider')).not.toBeInTheDocument() + expect(screen.getByText('Custom Provider')).toBeInTheDocument() + expect(screen.getByText('MCP Provider')).toBeInTheDocument() + + await user.type(screen.getByRole('textbox'), 'weather') + + await waitFor(() => { + expect(queryPluginsWithDebounced).toHaveBeenLastCalledWith({ + query: 'weather', + tags: [], + category: PluginCategoryEnum.tool, + }) + }) + + await waitFor(() => { + expect(screen.getByText('Weather Tool')).toBeInTheDocument() + }) + await user.click(screen.getByText('Weather Tool')) + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ + provider_name: 'custom-provider', + tool_name: 'weather-tool', + tool_label: 'Weather Tool', + })) + + await user.hover(screen.getByText('Custom Provider')) + await user.click(screen.getByText('workflow.tabs.addAll')) + + expect(onSelectMultiple).toHaveBeenCalledWith([ + expect.objectContaining({ + provider_name: 'custom-provider', + tool_name: 'weather-tool', + tool_label: 'Weather Tool', + }), + ]) + }) + + it('should create a custom collection from the add button and refresh custom tools', async () => { + const user = userEvent.setup() + const { container } = renderToolPicker({ + isShow: true, + supportAddCustomTool: true, + }) + + const addCustomToolButton = Array.from(container.querySelectorAll('button')).find((button) => { + return button.className.includes('bg-components-button-primary-bg') + }) + + expect(addCustomToolButton).toBeTruthy() + + await user.click(addCustomToolButton!) + expect(screen.getByTestId('edit-custom-tool-modal')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'submit-custom-tool' })) + + await waitFor(() => { + expect(mockCreateCustomCollection).toHaveBeenCalledWith({ name: 'collection-a' }) + }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.api.actionSuccess', + }) + expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1) + expect(screen.queryByTestId('edit-custom-tool-modal')).not.toBeInTheDocument() + }) + + it('should invalidate all tool collections after featured install succeeds', async () => { + const user = userEvent.setup() + + mockUseFeaturedToolsRecommendations.mockReturnValue({ + plugins: [createPlugin({ plugin_id: 'featured-1', latest_package_identifier: 'featured-1@1.0.0' })], + isLoading: false, + } as ReturnType) + + renderToolPicker({ + isShow: true, + selectedTools: [], + }) + + const featuredPluginItem = await screen.findByText('Plugin One') + await user.hover(featuredPluginItem) + await user.click(screen.getByRole('button', { name: 'plugin.installAction' })) + expect(await screen.findByTestId('install-from-marketplace')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'complete-featured-install' })) + + await waitFor(() => { + expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1) + expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1) + expect(mockInvalidateWorkflowTools).toHaveBeenCalledTimes(1) + expect(mockInvalidateMcpTools).toHaveBeenCalledTimes(1) + }, { timeout: 3000 }) + }) +}) diff --git a/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx b/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx new file mode 100644 index 0000000000..c3c3eaf911 --- /dev/null +++ b/web/app/components/workflow/datasets-detail-store/__tests__/provider.spec.tsx @@ -0,0 +1,91 @@ +import type { Node } from '../../types' +import type { DataSet } from '@/models/datasets' +import { render, screen, waitFor } from '@testing-library/react' +import { BlockEnum } from '../../types' +import DatasetsDetailProvider from '../provider' +import { useDatasetsDetailStore } from '../store' + +const mockFetchDatasets = vi.fn() + +vi.mock('@/service/datasets', () => ({ + fetchDatasets: (params: unknown) => mockFetchDatasets(params), +})) + +const Consumer = () => { + const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length) + return
{`dataset-count:${datasetCount}`}
+} + +const createWorkflowNode = (datasetIds: string[] = []): Node => ({ + id: `node-${datasetIds.join('-') || 'empty'}`, + type: 'custom', + position: { x: 0, y: 0 }, + data: { + title: 'Knowledge', + desc: '', + type: BlockEnum.KnowledgeRetrieval, + dataset_ids: datasetIds, + }, +} as unknown as Node) + +const createDataset = (id: string): DataSet => ({ + id, + name: `Dataset ${id}`, +} as DataSet) + +describe('datasets-detail-store provider', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchDatasets.mockResolvedValue({ data: [] }) + }) + + it('should provide the datasets detail store without fetching when no knowledge datasets are selected', () => { + render( + + + , + ) + + expect(screen.getByText('dataset-count:0')).toBeInTheDocument() + expect(mockFetchDatasets).not.toHaveBeenCalled() + }) + + it('should fetch unique dataset details from knowledge retrieval nodes and store them', async () => { + mockFetchDatasets.mockResolvedValue({ + data: [createDataset('dataset-1'), createDataset('dataset-2')], + }) + + render( + + + , + ) + + await waitFor(() => { + expect(mockFetchDatasets).toHaveBeenCalledWith({ + url: '/datasets', + params: { + page: 1, + ids: ['dataset-1', 'dataset-2'], + }, + }) + expect(screen.getByText('dataset-count:2')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx new file mode 100644 index 0000000000..dc00d61301 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx @@ -0,0 +1,308 @@ +import type { Shape } from '../../store/workflow' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { FlowType } from '@/types/common' +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import { WorkflowVersion } from '../../types' +import HeaderInNormal from '../header-in-normal' +import HeaderInRestoring from '../header-in-restoring' +import HeaderInHistory from '../header-in-view-history' + +const mockUseNodes = vi.fn() +const mockHandleBackupDraft = vi.fn() +const mockHandleLoadBackupDraft = vi.fn() +const mockHandleNodeSelect = vi.fn() +const mockHandleRefreshWorkflowDraft = vi.fn() +const mockCloseAllInputFieldPanels = vi.fn() +const mockInvalidAllLastRun = vi.fn() +const mockRestoreWorkflow = vi.fn() +const mockNotify = vi.fn() +const mockRunAndHistory = vi.fn() +const mockViewHistory = vi.fn() + +let mockNodesReadOnly = false +let mockTheme: 'light' | 'dark' = 'light' + +vi.mock('reactflow', () => ({ + useNodes: () => mockUseNodes(), +})) + +vi.mock('../../hooks', () => ({ + useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly }), + useNodesInteractions: () => ({ handleNodeSelect: mockHandleNodeSelect }), + useWorkflowRun: () => ({ + handleBackupDraft: mockHandleBackupDraft, + handleLoadBackupDraft: mockHandleLoadBackupDraft, + }), + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: vi.fn(), + }), + useWorkflowRefreshDraft: () => ({ + handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft, + }), +})) + +vi.mock('@/app/components/rag-pipeline/hooks', () => ({ + useInputFieldPanel: () => ({ + closeAllInputFieldPanels: mockCloseAllInputFieldPanels, + }), +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ + theme: mockTheme, + }), +})) + +vi.mock('@/service/use-workflow', () => ({ + useInvalidAllLastRun: () => mockInvalidAllLastRun, + useRestoreWorkflow: () => ({ + mutateAsync: mockRestoreWorkflow, + }), +})) + +vi.mock('../../../base/toast', () => ({ + default: { + notify: (payload: unknown) => mockNotify(payload), + }, +})) + +vi.mock('../editing-title', () => ({ + default: () =>
editing-title
, +})) + +vi.mock('../scroll-to-selected-node-button', () => ({ + default: () =>
scroll-button
, +})) + +vi.mock('../env-button', () => ({ + default: ({ disabled }: { disabled: boolean }) =>
{`${disabled}`}
, +})) + +vi.mock('../global-variable-button', () => ({ + default: ({ disabled }: { disabled: boolean }) =>
{`${disabled}`}
, +})) + +vi.mock('../run-and-history', () => ({ + default: (props: object) => { + mockRunAndHistory(props) + return
+ }, +})) + +vi.mock('../version-history-button', () => ({ + default: ({ onClick }: { onClick: () => void }) => ( + + ), +})) + +vi.mock('../restoring-title', () => ({ + default: () =>
restoring-title
, +})) + +vi.mock('../running-title', () => ({ + default: () =>
running-title
, +})) + +vi.mock('../view-history', () => ({ + default: (props: { withText?: boolean }) => { + mockViewHistory(props) + return
{props.withText ? 'with-text' : 'icon-only'}
+ }, +})) + +const createSelectedNode = (selected = true) => ({ + id: 'node-selected', + data: { + selected, + }, +}) + +const createBackupDraft = (): NonNullable => ({ + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + environmentVariables: [], +}) + +const createCurrentVersion = (): NonNullable => ({ + id: 'version-1', + graph: { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + created_at: 0, + created_by: { + id: 'user-1', + name: 'Tester', + email: 'tester@example.com', + }, + hash: 'hash-1', + updated_at: 0, + updated_by: { + id: 'user-1', + name: 'Tester', + email: 'tester@example.com', + }, + tool_published: false, + environment_variables: [], + version: WorkflowVersion.Latest, + marked_name: '', + marked_comment: '', +}) + +describe('Header layout components', () => { + beforeEach(() => { + vi.clearAllMocks() + mockNodesReadOnly = false + mockTheme = 'light' + mockUseNodes.mockReturnValue([]) + mockRestoreWorkflow.mockResolvedValue(undefined) + }) + + describe('HeaderInNormal', () => { + it('should render slots, pass read-only state to action buttons, and start restoring mode', () => { + mockNodesReadOnly = true + mockUseNodes.mockReturnValue([createSelectedNode()]) + + const { store } = renderWorkflowComponent( + left-slot
, + middle:
middle-slot
, + chatVariableTrigger:
chat-trigger
, + }} + />, + { + initialStoreState: { + showEnvPanel: true, + showDebugAndPreviewPanel: true, + showVariableInspectPanel: true, + showChatVariablePanel: true, + showGlobalVariablePanel: true, + }, + }, + ) + + expect(screen.getByText('editing-title')).toBeInTheDocument() + expect(screen.getByText('scroll-button')).toBeInTheDocument() + expect(screen.getByText('left-slot')).toBeInTheDocument() + expect(screen.getByText('middle-slot')).toBeInTheDocument() + expect(screen.getByText('chat-trigger')).toBeInTheDocument() + expect(screen.getByTestId('env-button')).toHaveTextContent('true') + expect(screen.getByTestId('global-variable-button')).toHaveTextContent('true') + expect(mockRunAndHistory).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'version-history' })) + + expect(mockHandleBackupDraft).toHaveBeenCalledTimes(1) + expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-selected', true) + expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1) + expect(store.getState().isRestoring).toBe(true) + expect(store.getState().showWorkflowVersionHistoryPanel).toBe(true) + expect(store.getState().showEnvPanel).toBe(false) + expect(store.getState().showDebugAndPreviewPanel).toBe(false) + expect(store.getState().showVariableInspectPanel).toBe(false) + expect(store.getState().showChatVariablePanel).toBe(false) + expect(store.getState().showGlobalVariablePanel).toBe(false) + }) + }) + + describe('HeaderInRestoring', () => { + it('should cancel restoring mode and reopen the editor state', () => { + const { store } = renderWorkflowComponent( + , + { + initialStoreState: { + isRestoring: true, + showWorkflowVersionHistoryPanel: true, + }, + hooksStoreProps: { + configsMap: { + flowType: FlowType.appFlow, + flowId: 'flow-1', + fileSettings: {}, + }, + }, + }, + ) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.exitVersions' })) + + expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1) + expect(store.getState().isRestoring).toBe(false) + expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false) + }) + + it('should restore the selected version, clear backup state, and forward lifecycle callbacks', async () => { + const onRestoreSettled = vi.fn() + const deleteAllInspectVars = vi.fn() + const currentVersion = createCurrentVersion() + + const { store } = renderWorkflowComponent( + , + { + initialStoreState: { + isRestoring: true, + showWorkflowVersionHistoryPanel: true, + backupDraft: createBackupDraft(), + currentVersion, + deleteAllInspectVars, + }, + hooksStoreProps: { + configsMap: { + flowType: FlowType.appFlow, + flowId: 'flow-1', + fileSettings: {}, + }, + }, + }, + ) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' })) + + await waitFor(() => { + expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/flow-1/workflows/version-1/restore') + expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false) + expect(store.getState().isRestoring).toBe(false) + expect(store.getState().backupDraft).toBeUndefined() + expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1) + expect(deleteAllInspectVars).toHaveBeenCalledTimes(1) + expect(mockInvalidAllLastRun).toHaveBeenCalledTimes(1) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'workflow.versionHistory.action.restoreSuccess', + }) + }) + expect(onRestoreSettled).toHaveBeenCalledTimes(1) + }) + }) + + describe('HeaderInHistory', () => { + it('should render the history trigger with text and return to edit mode', () => { + const { store } = renderWorkflowComponent( + , + { + initialStoreState: { + historyWorkflowData: { + id: 'history-1', + } as Shape['historyWorkflowData'], + }, + }, + ) + + expect(screen.getByText('running-title')).toBeInTheDocument() + expect(screen.getByTestId('view-history')).toHaveTextContent('with-text') + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.goBackToEdit' })) + + expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1) + expect(store.getState().historyWorkflowData).toBeUndefined() + expect(mockViewHistory).toHaveBeenCalledWith(expect.objectContaining({ + withText: true, + })) + }) + }) +}) diff --git a/web/app/components/workflow/header/__tests__/index.spec.tsx b/web/app/components/workflow/header/__tests__/index.spec.tsx new file mode 100644 index 0000000000..70d6fae88c --- /dev/null +++ b/web/app/components/workflow/header/__tests__/index.spec.tsx @@ -0,0 +1,106 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import Header from '../index' + +let mockPathname = '/apps/demo/workflow' +let mockMaximizeCanvas = false +let mockWorkflowMode = { + normal: true, + restoring: false, + viewHistory: false, +} + +vi.mock('@/next/navigation', () => ({ + usePathname: () => mockPathname, +})) + +vi.mock('../../hooks', () => ({ + useWorkflowMode: () => mockWorkflowMode, +})) + +vi.mock('../../store', () => ({ + useStore: (selector: (state: { maximizeCanvas: boolean }) => T) => selector({ + maximizeCanvas: mockMaximizeCanvas, + }), +})) + +vi.mock('@/next/dynamic', async () => { + const ReactModule = await import('react') + + return { + default: ( + loader: () => Promise<{ default: React.ComponentType> }>, + ) => { + const DynamicComponent = (props: Record) => { + const [Loaded, setLoaded] = ReactModule.useState> | null>(null) + + ReactModule.useEffect(() => { + let mounted = true + loader().then((mod) => { + if (mounted) + setLoaded(() => mod.default) + }) + return () => { + mounted = false + } + }, []) + + return Loaded ? : null + } + + return DynamicComponent + }, + } +}) + +vi.mock('../header-in-normal', () => ({ + default: () =>
normal-layout
, +})) + +vi.mock('../header-in-view-history', () => ({ + default: () =>
history-layout
, +})) + +vi.mock('../header-in-restoring', () => ({ + default: () =>
restoring-layout
, +})) + +describe('Header', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPathname = '/apps/demo/workflow' + mockMaximizeCanvas = false + mockWorkflowMode = { + normal: true, + restoring: false, + viewHistory: false, + } + }) + + it('should render the normal layout and show the maximize spacer on workflow canvases', () => { + mockMaximizeCanvas = true + + const { container } = render(
) + + expect(screen.getByTestId('header-normal')).toBeInTheDocument() + expect(screen.queryByTestId('header-history')).not.toBeInTheDocument() + expect(screen.queryByTestId('header-restoring')).not.toBeInTheDocument() + expect(container.querySelector('.h-14.w-\\[52px\\]')).not.toBeNull() + }) + + it('should switch between history and restoring layouts and skip the spacer outside canvas routes', async () => { + mockPathname = '/apps/demo/logs' + mockWorkflowMode = { + normal: false, + restoring: true, + viewHistory: true, + } + + const { container } = render(
) + + expect(await screen.findByTestId('header-history')).toBeInTheDocument() + expect(await screen.findByTestId('header-restoring')).toBeInTheDocument() + expect(screen.queryByTestId('header-normal')).not.toBeInTheDocument() + expect(container.querySelector('.h-14.w-\\[52px\\]')).toBeNull() + }) +}) diff --git a/web/app/components/workflow/hooks-store/__tests__/provider.spec.tsx b/web/app/components/workflow/hooks-store/__tests__/provider.spec.tsx new file mode 100644 index 0000000000..bbd9636e5e --- /dev/null +++ b/web/app/components/workflow/hooks-store/__tests__/provider.spec.tsx @@ -0,0 +1,73 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { useContext } from 'react' +import { HooksStoreContext, HooksStoreContextProvider } from '../provider' + +const mockRefreshAll = vi.fn() +const mockStore = { + getState: () => ({ + refreshAll: mockRefreshAll, + }), +} + +let mockReactflowState = { + d3Selection: null as object | null, + d3Zoom: null as object | null, +} + +vi.mock('reactflow', () => ({ + useStore: (selector: (state: typeof mockReactflowState) => unknown) => selector(mockReactflowState), +})) + +vi.mock('../store', async () => { + const actual = await vi.importActual('../store') + return { + ...actual, + createHooksStore: vi.fn(() => mockStore), + } +}) + +const Consumer = () => { + const store = useContext(HooksStoreContext) + return
{store ? 'has-hooks-store' : 'missing-hooks-store'}
+} + +describe('hooks-store provider', () => { + beforeEach(() => { + vi.clearAllMocks() + mockReactflowState = { + d3Selection: null, + d3Zoom: null, + } + }) + + it('should provide the hooks store context without refreshing when the canvas handles are missing', () => { + render( + + + , + ) + + expect(screen.getByText('has-hooks-store')).toBeInTheDocument() + expect(mockRefreshAll).not.toHaveBeenCalled() + }) + + it('should refresh the hooks store when both d3Selection and d3Zoom are available', async () => { + const handleRun = vi.fn() + mockReactflowState = { + d3Selection: {}, + d3Zoom: {}, + } + + render( + + + , + ) + + await waitFor(() => { + expect(mockRefreshAll).toHaveBeenCalledWith({ + handleRun, + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/__tests__/index.spec.tsx new file mode 100644 index 0000000000..41eb853a99 --- /dev/null +++ b/web/app/components/workflow/nodes/__tests__/index.spec.tsx @@ -0,0 +1,107 @@ +import type { ReactElement } from 'react' +import type { Node as WorkflowNode } from '../../types' +import { render, screen } from '@testing-library/react' +import { CUSTOM_NODE } from '../../constants' +import { BlockEnum } from '../../types' +import CustomNode, { Panel } from '../index' + +vi.mock('../components', () => ({ + NodeComponentMap: { + [BlockEnum.Start]: () =>
start-node-component
, + }, + PanelComponentMap: { + [BlockEnum.Start]: () =>
start-panel-component
, + }, +})) + +vi.mock('../_base/node', () => ({ + __esModule: true, + default: ({ + id, + data, + children, + }: { + id: string + data: { type: BlockEnum } + children: ReactElement + }) => ( +
+
{`base-node:${id}:${data.type}`}
+ {children} +
+ ), +})) + +vi.mock('../_base/components/workflow-panel', () => ({ + __esModule: true, + default: ({ + id, + data, + children, + }: { + id: string + data: { type: BlockEnum } + children: ReactElement + }) => ( +
+
{`base-panel:${id}:${data.type}`}
+ {children} +
+ ), +})) + +const createNodeData = (): WorkflowNode['data'] => ({ + title: 'Start', + desc: '', + type: BlockEnum.Start, +}) + +const baseNodeProps = { + type: CUSTOM_NODE, + selected: false, + zIndex: 1, + xPos: 0, + yPos: 0, + dragging: false, + isConnectable: true, +} + +describe('workflow nodes index', () => { + it('should render the mapped node inside the base node shell', () => { + render( + , + ) + + expect(screen.getByText('base-node:node-1:start')).toBeInTheDocument() + expect(screen.getByText('start-node-component')).toBeInTheDocument() + }) + + it('should render the mapped panel inside the base panel shell for custom nodes', () => { + render( + , + ) + + expect(screen.getByText('base-panel:node-1:start')).toBeInTheDocument() + expect(screen.getByText('start-panel-component')).toBeInTheDocument() + }) + + it('should return null for non-custom panel types', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx new file mode 100644 index 0000000000..ffe1e80bb0 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/__tests__/file-support.spec.tsx @@ -0,0 +1,226 @@ +import type { UploadFileSetting } from '@/app/components/workflow/types' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { useFileUploadConfig } from '@/service/use-common' +import { TransferMethod } from '@/types/app' +import FileTypeItem from '../file-type-item' +import FileUploadSetting from '../file-upload-setting' + +const mockUseFileUploadConfig = vi.mocked(useFileUploadConfig) +const mockUseFileSizeLimit = vi.mocked(useFileSizeLimit) + +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: vi.fn(), +})) + +vi.mock('@/app/components/base/file-uploader/hooks', () => ({ + useFileSizeLimit: vi.fn(), +})) + +vi.mock('@/app/components/base/toast/context', () => ({ + useToastContext: () => ({ + notify: vi.fn(), + close: vi.fn(), + }), +})) + +const createPayload = (overrides: Partial = {}): UploadFileSetting => ({ + allowed_file_upload_methods: [TransferMethod.local_file], + max_length: 2, + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: ['pdf'], + ...overrides, +}) + +describe('File upload support components', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseFileUploadConfig.mockReturnValue({ data: {} } as ReturnType) + mockUseFileSizeLimit.mockReturnValue({ + imgSizeLimit: 10 * 1024 * 1024, + docSizeLimit: 20 * 1024 * 1024, + audioSizeLimit: 30 * 1024 * 1024, + videoSizeLimit: 40 * 1024 * 1024, + maxFileUploadLimit: 10, + } as ReturnType) + }) + + describe('FileTypeItem', () => { + it('should render built-in file types and toggle the selected type on click', () => { + const onToggle = vi.fn() + + render( + , + ) + + expect(screen.getByText('appDebug.variableConfig.file.image.name')).toBeInTheDocument() + expect(screen.getByText('JPG, JPEG, PNG, GIF, WEBP, SVG')).toBeInTheDocument() + + fireEvent.click(screen.getByText('appDebug.variableConfig.file.image.name')) + expect(onToggle).toHaveBeenCalledWith(SupportUploadFileTypes.image) + }) + + it('should render the custom tag editor and emit custom extensions', async () => { + const user = userEvent.setup() + const onCustomFileTypesChange = vi.fn() + + render( + , + ) + + const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder') + await user.type(input, 'csv') + fireEvent.blur(input) + + expect(screen.getByText('json')).toBeInTheDocument() + expect(onCustomFileTypesChange).toHaveBeenCalledWith(['json', 'csv']) + }) + }) + + describe('FileUploadSetting', () => { + it('should update file types, upload methods, and upload limits', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('appDebug.variableConfig.file.image.name')) + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + allowed_file_types: [SupportUploadFileTypes.document, SupportUploadFileTypes.image], + })) + + await user.click(screen.getByText('URL')) + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + allowed_file_upload_methods: [TransferMethod.remote_url], + })) + + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '5' } }) + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + max_length: 5, + })) + }) + + it('should toggle built-in and custom file type selections', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const { rerender } = render( + , + ) + + await user.click(screen.getByText('appDebug.variableConfig.file.document.name')) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + allowed_file_types: [], + })) + + rerender( + , + ) + + await user.click(screen.getByText('appDebug.variableConfig.file.custom.name')) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + allowed_file_types: [SupportUploadFileTypes.custom], + })) + + rerender( + , + ) + + await user.click(screen.getByText('appDebug.variableConfig.file.custom.name')) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + allowed_file_types: [], + })) + }) + + it('should support both upload methods and update custom extensions', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const { rerender } = render( + , + ) + + await user.click(screen.getByText('appDebug.variableConfig.both')) + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + })) + + rerender( + , + ) + + const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder') + await user.type(input, 'csv') + fireEvent.blur(input) + + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ + allowed_file_extensions: ['pdf', 'csv'], + })) + }) + + it('should render support file types in the feature panel and hide them when requested', () => { + const { rerender } = render( + , + ) + + expect(screen.getByText('appDebug.variableConfig.file.supportFileTypes')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.queryByText('appDebug.variableConfig.file.document.name')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/__tests__/index.spec.tsx new file mode 100644 index 0000000000..9521f9b307 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/error-handle/__tests__/index.spec.tsx @@ -0,0 +1,250 @@ +import type { NodeProps } from 'reactflow' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createNode } from '@/app/components/workflow/__tests__/fixtures' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { NodeRunningStatus, VarType } from '@/app/components/workflow/types' +import DefaultValue from '../default-value' +import ErrorHandleOnNode from '../error-handle-on-node' +import ErrorHandleOnPanel from '../error-handle-on-panel' +import ErrorHandleTip from '../error-handle-tip' +import ErrorHandleTypeSelector from '../error-handle-type-selector' +import FailBranchCard from '../fail-branch-card' +import { useDefaultValue, useErrorHandle } from '../hooks' +import { ErrorHandleTypeEnum } from '../types' + +const { mockDocLink } = vi.hoisted(() => ({ + mockDocLink: vi.fn((path: string) => `https://docs.example.com${path}`), +})) + +vi.mock('@/context/i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useDocLink: () => mockDocLink, + } +}) + +vi.mock('../hooks', () => ({ + useDefaultValue: vi.fn(), + useErrorHandle: vi.fn(), +})) + +vi.mock('../../node-handle', () => ({ + NodeSourceHandle: ({ handleId }: { handleId: string }) =>
, +})) + +const mockUseDefaultValue = vi.mocked(useDefaultValue) +const mockUseErrorHandle = vi.mocked(useErrorHandle) +const originalDOMMatrixReadOnly = window.DOMMatrixReadOnly + +const baseData = (overrides: Partial = {}): CommonNodeType => ({ + title: 'Code', + desc: '', + type: 'code' as CommonNodeType['type'], + ...overrides, +}) + +const ErrorHandleNodeHarness = ({ id, data }: NodeProps) => ( + +) + +const renderErrorHandleNode = (data: CommonNodeType) => + renderWorkflowFlowComponent(
, { + nodes: [createNode({ + id: 'node-1', + type: 'errorHandleNode', + data, + })], + edges: [], + reactFlowProps: { + nodeTypes: { + errorHandleNode: ErrorHandleNodeHarness, + }, + }, + }) + +describe('error-handle path', () => { + beforeAll(() => { + class MockDOMMatrixReadOnly { + inverse() { + return this + } + + transformPoint(point: { x: number, y: number }) { + return point + } + } + + Object.defineProperty(window, 'DOMMatrixReadOnly', { + configurable: true, + writable: true, + value: MockDOMMatrixReadOnly, + }) + }) + + beforeEach(() => { + vi.clearAllMocks() + mockDocLink.mockImplementation((path: string) => `https://docs.example.com${path}`) + mockUseDefaultValue.mockReturnValue({ + handleFormChange: vi.fn(), + }) + mockUseErrorHandle.mockReturnValue({ + collapsed: false, + setCollapsed: vi.fn(), + handleErrorHandleTypeChange: vi.fn(), + }) + }) + + afterAll(() => { + Object.defineProperty(window, 'DOMMatrixReadOnly', { + configurable: true, + writable: true, + value: originalDOMMatrixReadOnly, + }) + }) + + // The error-handle leaf components should expose selectable strategies and contextual help. + describe('Leaf Components', () => { + it('should render the fail-branch card with the resolved learn-more link', () => { + render() + + expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument() + expect(screen.getByRole('link')).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type') + }) + + it('should render string forms and surface array forms in the default value editor', () => { + const onFormChange = vi.fn() + render( + , + ) + + fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated' } }) + + expect(onFormChange).toHaveBeenCalledWith({ + key: 'message', + type: VarType.string, + value: 'updated', + }) + expect(screen.getByText('items')).toBeInTheDocument() + }) + + it('should toggle the selector popup and report the selected strategy', async () => { + const user = userEvent.setup() + const onSelected = vi.fn() + render( + , + ) + + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.title')) + + expect(onSelected).toHaveBeenCalledWith(ErrorHandleTypeEnum.defaultValue) + }) + + it('should render the error tip only when a strategy exists', () => { + const { rerender, container } = render() + + expect(container).toBeEmptyDOMElement() + + rerender() + expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.inLog')).toBeInTheDocument() + + rerender() + expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.inLog')).toBeInTheDocument() + }) + }) + + // The container components should show the correct branch card or default-value editor and propagate actions. + describe('Containers', () => { + it('should render the fail-branch panel body when the strategy is active', () => { + render( + , + ) + + expect(screen.getByText('workflow.nodes.common.errorHandle.title')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument() + }) + + it('should render the default-value panel body and delegate form updates', () => { + const handleFormChange = vi.fn() + mockUseDefaultValue.mockReturnValue({ handleFormChange }) + render( + , + ) + + fireEvent.change(screen.getByDisplayValue('draft'), { target: { value: 'next' } }) + + expect(handleFormChange).toHaveBeenCalledWith( + { key: 'answer', type: VarType.string, value: 'next' }, + expect.objectContaining({ error_strategy: ErrorHandleTypeEnum.defaultValue }), + ) + }) + + it('should hide the panel body when the hook reports a collapsed section', () => { + mockUseErrorHandle.mockReturnValue({ + collapsed: true, + setCollapsed: vi.fn(), + handleErrorHandleTypeChange: vi.fn(), + }) + + render( + , + ) + + expect(screen.queryByText('workflow.nodes.common.errorHandle.failBranch.customize')).not.toBeInTheDocument() + }) + + it('should render the default-value node badge', () => { + renderWorkflowFlowComponent( + , + { + nodes: [], + edges: [], + }, + ) + + expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument() + }) + + it('should render the fail-branch node badge when the node throws an exception', () => { + const { container } = renderErrorHandleNode(baseData({ + error_strategy: ErrorHandleTypeEnum.failBranch, + _runningStatus: NodeRunningStatus.Exception, + })) + + return waitFor(() => { + expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument() + expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch) + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/input-field/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/input-field/__tests__/index.spec.tsx index a6d6d0bf6c..38736c573d 100644 --- a/web/app/components/workflow/nodes/_base/components/input-field/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/input-field/__tests__/index.spec.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react' +import Add from '../add' import InputField from '../index' describe('InputField', () => { @@ -14,5 +15,12 @@ describe('InputField', () => { expect(screen.getAllByText('input field')).toHaveLength(2) expect(screen.getByRole('button')).toBeInTheDocument() }) + + it('should render the standalone add action button', () => { + const { container } = render() + + expect(screen.getByRole('button')).toBeInTheDocument() + expect(container.querySelector('svg')).not.toBeNull() + }) }) }) diff --git a/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx index 680965eb06..071e7f011b 100644 --- a/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/layout/__tests__/index.spec.tsx @@ -1,13 +1,47 @@ import { render, screen } from '@testing-library/react' -import { BoxGroupField, FieldTitle } from '../index' +import userEvent from '@testing-library/user-event' +import { Box, BoxGroup, BoxGroupField, Field, Group, GroupField } from '../index' describe('layout index', () => { beforeEach(() => { vi.clearAllMocks() }) - // The barrel exports should compose the public layout primitives without extra wrappers. + // The layout primitives should preserve their composition contracts and collapse behavior. describe('Rendering', () => { + it('should render Box and Group with optional border styles', () => { + render( +
+ Box content + Group content +
, + ) + + expect(screen.getByText('Box content')).toHaveClass('border-b', 'box-test') + expect(screen.getByText('Group content')).toHaveClass('border-b', 'group-test') + }) + + it('should render BoxGroup and GroupField with nested children', () => { + render( +
+ Inside box group + + Group field body + +
, + ) + + expect(screen.getByText('Inside box group')).toBeInTheDocument() + expect(screen.getByText('Grouped field')).toBeInTheDocument() + expect(screen.getByText('Group field body')).toBeInTheDocument() + }) + it('should render BoxGroupField from the barrel export', () => { render( { expect(screen.getByText('Body content')).toBeInTheDocument() }) - it('should render FieldTitle from the barrel export', () => { - render() + it('should collapse and expand Field children when supportCollapse is enabled', async () => { + const user = userEvent.setup() + render( + +
Extra details
+
, + ) - expect(screen.getByText('Advanced')).toBeInTheDocument() + expect(screen.getByText('Extra details')).toBeInTheDocument() + + await user.click(screen.getByText('Advanced')) + expect(screen.queryByText('Extra details')).not.toBeInTheDocument() + + await user.click(screen.getByText('Advanced')) expect(screen.getByText('Extra details')).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/index.spec.tsx new file mode 100644 index 0000000000..1c68990d34 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/index.spec.tsx @@ -0,0 +1,114 @@ +import type { PromptEditorProps } from '@/app/components/base/prompt-editor' +import type { + Node, + NodeOutPutVar, +} from '@/app/components/workflow/types' +import { render } from '@testing-library/react' +import { BlockEnum } from '@/app/components/workflow/types' +import MixedVariableTextInput from '../index' + +let capturedPromptEditorProps: PromptEditorProps[] = [] + +vi.mock('@/app/components/base/prompt-editor', () => ({ + default: ({ + editable, + value, + workflowVariableBlock, + onChange, + }: PromptEditorProps) => { + capturedPromptEditorProps.push({ + editable, + value, + onChange, + workflowVariableBlock, + }) + + return ( +
+
{editable ? 'editable' : 'readonly'}
+
{value || 'empty'}
+ +
+ ) + }, +})) + +describe('MixedVariableTextInput', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedPromptEditorProps = [] + }) + + it('should pass workflow variable metadata to the prompt editor and include system variables for start nodes', () => { + const nodesOutputVars: NodeOutPutVar[] = [{ + nodeId: 'node-1', + title: 'Question Node', + vars: [], + }] + const availableNodes: Node[] = [ + { + id: 'start-node', + position: { x: 0, y: 0 }, + data: { + title: 'Start Node', + desc: 'Start description', + type: BlockEnum.Start, + }, + }, + { + id: 'llm-node', + position: { x: 120, y: 0 }, + data: { + title: 'LLM Node', + desc: 'LLM description', + type: BlockEnum.LLM, + }, + }, + ] + + render( + , + ) + + const latestProps = capturedPromptEditorProps.at(-1) + + expect(latestProps?.editable).toBe(true) + expect(latestProps?.workflowVariableBlock?.variables).toHaveLength(1) + expect(latestProps?.workflowVariableBlock?.workflowNodesMap).toEqual({ + 'start-node': { + title: 'Start Node', + type: 'start', + }, + 'sys': { + title: 'workflow.blocks.start', + type: 'start', + }, + 'llm-node': { + title: 'LLM Node', + type: 'llm', + }, + }) + }) + + it('should forward read-only state, current value, and change callbacks', async () => { + const onChange = vi.fn() + const { findByRole, getByTestId } = render( + , + ) + + expect(getByTestId('editable-flag')).toHaveTextContent('readonly') + expect(getByTestId('value-flag')).toHaveTextContent('seed value') + + const changeButton = await findByRole('button', { name: 'trigger-change' }) + changeButton.click() + + expect(onChange).toHaveBeenCalledWith('updated text') + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx new file mode 100644 index 0000000000..03e67f68de --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/__tests__/placeholder.spec.tsx @@ -0,0 +1,78 @@ +import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext' +import type { LexicalEditor } from 'lexical' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { createEvent, fireEvent, render, screen } from '@testing-library/react' +import { $insertNodes, FOCUS_COMMAND } from 'lexical' +import Placeholder from '../placeholder' + +const mockEditorUpdate = vi.fn((callback: () => void) => callback()) +const mockDispatchCommand = vi.fn() +const mockInsertNodes = vi.fn() +const mockTextNode = vi.fn() + +const mockEditor = { + update: mockEditorUpdate, + dispatchCommand: mockDispatchCommand, +} as unknown as LexicalEditor + +const lexicalContextValue: LexicalComposerContextWithEditor = [ + mockEditor, + { getTheme: () => undefined }, +] + +vi.mock('@lexical/react/LexicalComposerContext', () => ({ + useLexicalComposerContext: vi.fn(), +})) + +vi.mock('lexical', () => ({ + $insertNodes: vi.fn(), + FOCUS_COMMAND: 'focus-command', +})) + +vi.mock('@/app/components/base/prompt-editor/plugins/custom-text/node', () => ({ + CustomTextNode: class MockCustomTextNode { + value: string + + constructor(value: string) { + this.value = value + mockTextNode(value) + } + }, +})) + +describe('Mixed variable placeholder', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useLexicalComposerContext).mockReturnValue(lexicalContextValue) + vi.mocked($insertNodes).mockImplementation(nodes => mockInsertNodes(nodes)) + }) + + it('should insert an empty text node and focus the editor when the placeholder background is clicked', () => { + const parentClick = vi.fn() + + render( +
+ +
, + ) + + fireEvent.click(screen.getByText('workflow.nodes.tool.insertPlaceholder1')) + + expect(parentClick).not.toHaveBeenCalled() + expect(mockTextNode).toHaveBeenCalledWith('') + expect(mockInsertNodes).toHaveBeenCalledTimes(1) + expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined) + }) + + it('should insert a slash shortcut from the highlighted action and prevent the native mouse down behavior', () => { + render() + + const shortcut = screen.getByText('workflow.nodes.tool.insertPlaceholder2') + const event = createEvent.mouseDown(shortcut) + fireEvent(shortcut, event) + + expect(event.defaultPrevented).toBe(true) + expect(mockTextNode).toHaveBeenCalledWith('/') + expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx new file mode 100644 index 0000000000..3e02aba077 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx @@ -0,0 +1,268 @@ +/* eslint-disable ts/no-explicit-any */ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { + useAvailableBlocks, + useIsChatMode, + useNodeDataUpdate, + useNodeMetaData, + useNodesInteractions, + useNodesReadOnly, + useNodesSyncDraft, +} from '@/app/components/workflow/hooks' +import { useHooksStore } from '@/app/components/workflow/hooks-store' +import useNodes from '@/app/components/workflow/store/workflow/use-nodes' +import { BlockEnum } from '@/app/components/workflow/types' +import { useAllWorkflowTools } from '@/service/use-tools' +import { FlowType } from '@/types/common' +import ChangeBlock from '../change-block' +import PanelOperatorPopup from '../panel-operator-popup' + +vi.mock('@/app/components/workflow/block-selector', () => ({ + default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => ( +
+
{trigger()}
+
{`available:${(availableBlocksTypes || []).join(',')}`}
+
{`show-start:${String(showStartTab)}`}
+
{`ignore:${(ignoreNodeIds || []).join(',')}`}
+
{`force-start:${String(forceEnableStartTab)}`}
+
{`allow-start:${String(allowUserInputSelection)}`}
+ +
+ ), +})) + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAvailableBlocks: vi.fn(), + useIsChatMode: vi.fn(), + useNodeDataUpdate: vi.fn(), + useNodeMetaData: vi.fn(), + useNodesInteractions: vi.fn(), + useNodesReadOnly: vi.fn(), + useNodesSyncDraft: vi.fn(), + } +}) + +vi.mock('@/app/components/workflow/hooks-store', () => ({ + useHooksStore: vi.fn(), +})) + +vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + default: vi.fn(), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllWorkflowTools: vi.fn(), +})) + +const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks) +const mockUseIsChatMode = vi.mocked(useIsChatMode) +const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate) +const mockUseNodeMetaData = vi.mocked(useNodeMetaData) +const mockUseNodesInteractions = vi.mocked(useNodesInteractions) +const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) +const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft) +const mockUseHooksStore = vi.mocked(useHooksStore) +const mockUseNodes = vi.mocked(useNodes) +const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools) + +describe('panel-operator details', () => { + const handleNodeChange = vi.fn() + const handleNodeDelete = vi.fn() + const handleNodesDuplicate = vi.fn() + const handleNodeSelect = vi.fn() + const handleNodesCopy = vi.fn() + const handleNodeDataUpdate = vi.fn() + const handleSyncWorkflowDraft = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockUseAvailableBlocks.mockReturnValue({ + getAvailableBlocks: vi.fn(() => ({ + availablePrevBlocks: [BlockEnum.HttpRequest], + availableNextBlocks: [BlockEnum.HttpRequest], + })), + availablePrevBlocks: [BlockEnum.HttpRequest], + availableNextBlocks: [BlockEnum.HttpRequest], + } as ReturnType) + mockUseIsChatMode.mockReturnValue(false) + mockUseNodeDataUpdate.mockReturnValue({ + handleNodeDataUpdate, + handleNodeDataUpdateWithSyncDraft: vi.fn(), + }) + mockUseNodeMetaData.mockReturnValue({ + isTypeFixed: false, + isSingleton: false, + isUndeletable: false, + description: 'Node description', + author: 'Dify', + helpLinkUri: 'https://docs.example.com/node', + } as ReturnType) + mockUseNodesInteractions.mockReturnValue({ + handleNodeChange, + handleNodeDelete, + handleNodesDuplicate, + handleNodeSelect, + handleNodesCopy, + } as unknown as ReturnType) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType) + mockUseNodesSyncDraft.mockReturnValue({ + doSyncWorkflowDraft: vi.fn(), + handleSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose: vi.fn(), + } as ReturnType) + mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) + mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any) + mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any) + }) + + // The panel operator internals should expose block-change and popup actions using the real workflow popup composition. + describe('Internal Actions', () => { + it('should select a replacement block through ChangeBlock', async () => { + const user = userEvent.setup() + render( + , + ) + + await user.click(screen.getByText('select-http')) + + expect(screen.getByText('available:http-request')).toBeInTheDocument() + expect(screen.getByText('show-start:true')).toBeInTheDocument() + expect(screen.getByText('ignore:')).toBeInTheDocument() + expect(screen.getByText('force-start:false')).toBeInTheDocument() + expect(screen.getByText('allow-start:false')).toBeInTheDocument() + expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined) + }) + + it('should expose trigger and start-node specific block selector options', () => { + mockUseAvailableBlocks.mockReturnValueOnce({ + getAvailableBlocks: vi.fn(() => ({ + availablePrevBlocks: [], + availableNextBlocks: [BlockEnum.HttpRequest], + })), + availablePrevBlocks: [], + availableNextBlocks: [BlockEnum.HttpRequest], + } as ReturnType) + mockUseIsChatMode.mockReturnValueOnce(true) + mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) + mockUseNodes.mockReturnValueOnce([] as any) + + const { rerender } = render( + , + ) + + expect(screen.getByText('available:http-request')).toBeInTheDocument() + expect(screen.getByText('show-start:true')).toBeInTheDocument() + expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument() + expect(screen.getByText('allow-start:true')).toBeInTheDocument() + + mockUseAvailableBlocks.mockReturnValueOnce({ + getAvailableBlocks: vi.fn(() => ({ + availablePrevBlocks: [BlockEnum.Code], + availableNextBlocks: [], + })), + availablePrevBlocks: [BlockEnum.Code], + availableNextBlocks: [], + } as ReturnType) + mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } })) + mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any) + + rerender( + , + ) + + expect(screen.getByText('available:code')).toBeInTheDocument() + expect(screen.getByText('show-start:false')).toBeInTheDocument() + expect(screen.getByText('ignore:start-node')).toBeInTheDocument() + expect(screen.getByText('force-start:true')).toBeInTheDocument() + }) + + it('should run, copy, duplicate, delete, and expose the help link in the popup', async () => { + const user = userEvent.setup() + renderWorkflowFlowComponent( + , + { + nodes: [], + edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }], + }, + ) + + await user.click(screen.getByText('workflow.panel.runThisStep')) + await user.click(screen.getByText('workflow.common.copy')) + await user.click(screen.getByText('workflow.common.duplicate')) + await user.click(screen.getByText('common.operation.delete')) + + expect(handleNodeSelect).toHaveBeenCalledWith('node-1') + expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } }) + expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true) + expect(handleNodesCopy).toHaveBeenCalledWith('node-1') + expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1') + expect(handleNodeDelete).toHaveBeenCalledWith('node-1') + expect(screen.getByRole('link', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node') + }) + + it('should render workflow-tool and readonly popup variants', () => { + mockUseAllWorkflowTools.mockReturnValueOnce({ + data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }], + } as any) + + const { rerender } = renderWorkflowFlowComponent( + , + { + nodes: [], + edges: [], + }, + ) + + expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow') + + mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType) + mockUseNodeMetaData.mockReturnValueOnce({ + isTypeFixed: true, + isSingleton: true, + isUndeletable: true, + description: 'Read only node', + author: 'Dify', + } as ReturnType) + + rerender( + , + ) + + expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument() + expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/support-var-input/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/support-var-input/__tests__/index.spec.tsx new file mode 100644 index 0000000000..5fbab5e497 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/support-var-input/__tests__/index.spec.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SupportVarInput from '../index' + +describe('SupportVarInput', () => { + it('should render plain text, highlighted variables, and preserved line breaks', () => { + render() + + expect(screen.getByText('World').closest('[title]')).toHaveAttribute('title', 'Hello {{user_name}}\nWorld') + expect(screen.getByText('user_name')).toBeInTheDocument() + expect(screen.getByText('Hello')).toBeInTheDocument() + expect(screen.getByText('World')).toBeInTheDocument() + }) + + it('should show the focused child content and call onFocus when activated', async () => { + const user = userEvent.setup() + const onFocus = vi.fn() + + render( + + + , + ) + + const editor = screen.getByRole('textbox', { name: 'inline-editor' }) + expect(editor).toBeInTheDocument() + expect(screen.queryByTitle('draft')).not.toBeInTheDocument() + + await user.click(editor) + + expect(onFocus).toHaveBeenCalledTimes(1) + }) + + it('should keep the static preview visible when the input is read-only', () => { + render( + + + , + ) + + expect(screen.queryByRole('textbox', { name: 'hidden-editor' })).not.toBeInTheDocument() + expect(screen.getByTitle('readonly content')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/assigned-var-reference-popup.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/assigned-var-reference-popup.spec.tsx new file mode 100644 index 0000000000..e1a7ae4a4b --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/assigned-var-reference-popup.spec.tsx @@ -0,0 +1,72 @@ +import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' +import { render, screen } from '@testing-library/react' +import { VarType } from '@/app/components/workflow/types' +import AssignedVarReferencePopup from '../assigned-var-reference-popup' + +const mockVarReferenceVars = vi.fn() + +vi.mock('../var-reference-vars', () => ({ + default: ({ + vars, + onChange, + itemWidth, + isSupportFileVar, + }: { + vars: NodeOutPutVar[] + onChange: (value: ValueSelector, item: Var) => void + itemWidth?: number + isSupportFileVar?: boolean + }) => { + mockVarReferenceVars({ vars, onChange, itemWidth, isSupportFileVar }) + return
{vars.length}
+ }, +})) + +const createOutputVar = (overrides: Partial = {}): NodeOutPutVar => ({ + nodeId: 'node-1', + title: 'Node One', + vars: [{ + variable: 'answer', + type: VarType.string, + }], + ...overrides, +}) + +describe('AssignedVarReferencePopup', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the empty state when there are no assigned variables', () => { + render( + , + ) + + expect(screen.getByText('workflow.nodes.assigner.noAssignedVars')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.assigner.assignedVarsDescription')).toBeInTheDocument() + expect(screen.queryByTestId('var-reference-vars')).not.toBeInTheDocument() + }) + + it('should delegate populated variable lists to the variable picker with file support enabled', () => { + const onChange = vi.fn() + + render( + , + ) + + expect(screen.getByTestId('var-reference-vars')).toHaveTextContent('1') + expect(mockVarReferenceVars).toHaveBeenCalledWith({ + vars: [createOutputVar()], + onChange, + itemWidth: 280, + isSupportFileVar: true, + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx index cb44e93427..d75e6b6036 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/__tests__/index.spec.tsx @@ -1,6 +1,11 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { BlockEnum, VarType } from '@/app/components/workflow/types' -import { VariableLabelInNode, VariableLabelInText } from '../index' +import VariableIcon from '../base/variable-icon' +import VariableLabel from '../base/variable-label' +import VariableName from '../base/variable-name' +import VariableNodeLabel from '../base/variable-node-label' +import { VariableIconWithColor, VariableLabelInEditor, VariableLabelInNode, VariableLabelInSelect, VariableLabelInText } from '../index' describe('variable-label index', () => { beforeEach(() => { @@ -39,5 +44,96 @@ describe('variable-label index', () => { expect(screen.getByText('Source Node')).toBeInTheDocument() expect(screen.getByText('answer')).toBeInTheDocument() }) + + it('should render the select variant with the full variable path', () => { + render( + , + ) + + expect(screen.getByText('payload.answer')).toBeInTheDocument() + }) + + it('should render the editor variant with selected styles and inline error feedback', async () => { + const user = userEvent.setup() + const { container } = render( + suffix} + />, + ) + + const badge = screen.getByText('payload').closest('div') + expect(badge).toBeInTheDocument() + expect(screen.getByText('suffix')).toBeInTheDocument() + + await user.hover(screen.getByText('payload')) + + expect(container.querySelector('[data-icon="Warning"]')).not.toBeNull() + }) + + it('should render the icon helpers for environment and exception variables', () => { + const { container } = render( +
+ + +
, + ) + + expect(container.querySelectorAll('svg').length).toBeGreaterThan(0) + }) + + it('should render the base variable name with shortened path and title', () => { + render( + , + ) + + expect(screen.getByText('answer')).toHaveAttribute('title', 'answer') + }) + + it('should render the base node label only when node type exists', () => { + const { container, rerender } = render() + + expect(container).toBeEmptyDOMElement() + + rerender( + , + ) + + expect(screen.getByText('Code Node')).toBeInTheDocument() + }) + + it('should render the base label with variable type and right slot', () => { + render( + slot} + />, + ) + + expect(screen.getByText('Source Node')).toBeInTheDocument() + expect(screen.getByText('query')).toBeInTheDocument() + expect(screen.getByText('String')).toBeInTheDocument() + expect(screen.getByText('slot')).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx new file mode 100644 index 0000000000..a7913ae0aa --- /dev/null +++ b/web/app/components/workflow/nodes/agent/__tests__/integration.spec.tsx @@ -0,0 +1,340 @@ +/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */ +import type { AgentNodeType } from '../types' +import type { StrategyParamItem } from '@/app/components/plugins/types' +import type { PanelProps } from '@/types/workflow' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { BlockEnum } from '@/app/components/workflow/types' +import { VarType as ToolVarType } from '../../tool/types' +import { ModelBar } from '../components/model-bar' +import { ToolIcon } from '../components/tool-icon' +import Node from '../node' +import Panel from '../panel' +import { AgentFeature } from '../types' +import useConfig from '../use-config' + +let mockTextGenerationModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] +let mockModerationModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] +let mockRerankModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] +let mockSpeech2TextModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] +let mockTextEmbeddingModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] +let mockTtsModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = [] + +let mockBuiltInTools: Array | undefined = [] +let mockCustomTools: Array | undefined = [] +let mockWorkflowTools: Array | undefined = [] +let mockMcpTools: Array | undefined = [] +let mockMarketplaceIcon: string | Record | undefined + +const mockResetEditor = vi.fn() + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: (modelType: ModelTypeEnum) => { + if (modelType === ModelTypeEnum.textGeneration) + return { data: mockTextGenerationModels } + if (modelType === ModelTypeEnum.moderation) + return { data: mockModerationModels } + if (modelType === ModelTypeEnum.rerank) + return { data: mockRerankModels } + if (modelType === ModelTypeEnum.speech2text) + return { data: mockSpeech2TextModels } + if (modelType === ModelTypeEnum.textEmbedding) + return { data: mockTextEmbeddingModels } + return { data: mockTtsModels } + }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: ({ defaultModel, modelList }: any) => ( +
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}:{modelList.length}
+ ), +})) + +vi.mock('@/app/components/header/indicator', () => ({ + default: ({ color }: any) =>
{`indicator:${color}`}
, +})) + +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 }: any) =>
{`app-icon:${background}:${icon}`}
, +})) + +vi.mock('@/app/components/base/icons/src/vender/other', () => ({ + Group: () =>
group-icon
, +})) + +vi.mock('@/utils/get-icon', () => ({ + getIconFromMarketPlace: () => mockMarketplaceIcon, +})) + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (value: string) => value, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/group', () => ({ + Group: ({ label, children }: any) =>
{label}
{children}
, + GroupLabel: ({ className, children }: any) =>
{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/setting-item', () => ({ + SettingItem: ({ label, status, tooltip, children }: any) =>
{label}:{status}:{tooltip}:{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ + default: ({ title, children }: any) =>
{title}
{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/agent-strategy', () => ({ + AgentStrategy: ({ onStrategyChange }: any) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({ + MCPToolAvailabilityProvider: ({ children }: any) =>
{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({ + default: ({ onChange }: any) => , +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ + default: ({ children }: any) =>
{children}
, + VarItem: ({ name, type, description }: any) =>
{`${name}:${type}:${description}`}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ + default: () =>
split
, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { setControlPromptEditorRerenderKey: typeof mockResetEditor }) => unknown) => selector({ + setControlPromptEditorRerenderKey: mockResetEditor, + }), +})) + +vi.mock('@/utils/plugin-version-feature', () => ({ + isSupportMCP: () => true, +})) + +vi.mock('../use-config', () => ({ + default: vi.fn(), +})) + +const mockUseConfig = vi.mocked(useConfig) + +const createStrategyParam = ( + name: string, + type: FormTypeEnum, + required: boolean, +): StrategyParamItem => ({ + name, + type, + required, + label: { en_US: name } as StrategyParamItem['label'], + help: { en_US: `${name} help` } as StrategyParamItem['help'], + placeholder: { en_US: `${name} placeholder` } as StrategyParamItem['placeholder'], + scope: 'global', + default: null, + options: [], + template: { enabled: false }, + auto_generate: { type: 'none' }, +}) + +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: { + modelParam: { type: ToolVarType.constant, value: { provider: 'openai', model: 'gpt-4o' } }, + toolParam: { type: ToolVarType.constant, value: { provider_name: 'author/tool-a' } }, + multiToolParam: { type: ToolVarType.constant, value: [{ provider_name: 'author/tool-b' }] }, + }, + meta: { version: '1.0.0' } as any, + plugin_unique_identifier: 'provider/agent:1.0.0', + ...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 any, + provider: 'provider/agent', + }, + parameters: [ + createStrategyParam('modelParam', FormTypeEnum.modelSelector, true), + createStrategyParam('optionalModel', FormTypeEnum.modelSelector, false), + createStrategyParam('toolParam', FormTypeEnum.toolSelector, false), + createStrategyParam('multiToolParam', FormTypeEnum.multiToolSelector, false), + ], + description: { en_US: 'agent description' } as any, + output_schema: {}, + features: [AgentFeature.HISTORY_MESSAGES], + }, + formData: {}, + onFormChange: vi.fn(), + currentStrategyStatus: { + plugin: { source: 'marketplace', installed: true }, + isExistInPlugin: false, + }, + strategyProvider: undefined, + pluginDetail: { + declaration: { + label: 'Mock Plugin', + }, + } as any, + availableVars: [], + availableNodesWithParent: [], + outputSchema: [{ name: 'jsonField', type: 'String', description: 'json output' }], + handleMemoryChange: vi.fn(), + isChatMode: true, + ...overrides, +}) + +const panelProps: PanelProps = { + getInputVars: vi.fn(() => []), + toVarInputs: vi.fn(() => []), + runInputData: {}, + runInputDataRef: { current: {} }, + setRunInputData: vi.fn(), + runResult: null, +} + +describe('agent path', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTextGenerationModels = [{ provider: 'openai', models: [{ model: 'gpt-4o' }] }] + mockModerationModels = [] + mockRerankModels = [] + mockSpeech2TextModels = [] + mockTextEmbeddingModels = [] + mockTtsModels = [] + mockBuiltInTools = [{ name: 'author/tool-a', is_team_authorization: true, icon: 'https://example.com/icon-a.png' }] + mockCustomTools = [] + mockWorkflowTools = [{ id: 'author/tool-b', is_team_authorization: false, icon: { content: 'B', background: '#fff' } }] + mockMcpTools = [] + mockMarketplaceIcon = 'https://example.com/marketplace.png' + mockUseConfig.mockReturnValue(createConfigResult()) + }) + + describe('Path Integration', () => { + it('should render model bars for missing, installed, and missing-install models', () => { + const { rerender, container } = render() + + expect(container).toHaveTextContent('no-model:0') + expect(screen.getByText('indicator:red')).toBeInTheDocument() + + rerender() + expect(container).toHaveTextContent('openai/gpt-4o:1') + expect(screen.queryByText('indicator:red')).not.toBeInTheDocument() + + rerender() + expect(container).toHaveTextContent('openai/gpt-4.1:1') + expect(screen.getByText('indicator:red')).toBeInTheDocument() + }) + + it('should render tool icons across loading, marketplace fallback, authorization warning, and fetch-error states', async () => { + const user = userEvent.setup() + const { unmount } = render() + + expect(screen.getByRole('img', { name: 'tool icon' })).toBeInTheDocument() + + fireEvent.error(screen.getByRole('img', { name: 'tool icon' })) + expect(screen.getByText('group-icon')).toBeInTheDocument() + + unmount() + const secondRender = render() + expect(screen.getByText('app-icon:#fff:B')).toBeInTheDocument() + expect(screen.getByText('indicator:yellow')).toBeInTheDocument() + + mockBuiltInTools = undefined + secondRender.rerender() + expect(screen.getByText('group-icon')).toBeInTheDocument() + + mockBuiltInTools = [] + secondRender.rerender() + expect(screen.getByRole('img', { name: 'tool icon' })).toBeInTheDocument() + await user.unhover(screen.getByRole('img', { name: 'tool icon' })) + }) + + it('should render strategy, models, and toolbox entries in the node', () => { + const { container } = render( + , + ) + + expect(screen.getByText(/workflow\.nodes\.agent\.strategy\.shortLabel/)).toBeInTheDocument() + expect(container).toHaveTextContent('React Agent') + expect(screen.getByText('workflow.nodes.agent.model')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.agent.toolbox')).toBeInTheDocument() + expect(container).toHaveTextContent('openai/gpt-4o:1') + expect(screen.getByText('indicator:yellow')).toBeInTheDocument() + }) + + it('should render the panel, update the selected strategy, and expose memory plus output vars', async () => { + const user = userEvent.setup() + const config = createConfigResult() + mockUseConfig.mockReturnValue(config) + + render( + , + ) + + expect(screen.getByText('workflow.nodes.agent.strategy.label')).toBeInTheDocument() + expect(screen.getByText('text:String:workflow.nodes.agent.outputVars.text')).toBeInTheDocument() + expect(screen.getByText('jsonField:String:json output')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'change-strategy' })) + expect(config.setInputs).toHaveBeenCalledWith(expect.objectContaining({ + agent_strategy_provider_name: 'provider/updated', + agent_strategy_name: 'updated-strategy', + agent_strategy_label: 'Updated Strategy', + plugin_unique_identifier: 'provider/updated:1.0.0', + })) + expect(mockResetEditor).toHaveBeenCalledTimes(1) + + await user.click(screen.getByRole('button', { name: 'change-memory' })) + expect(config.handleMemoryChange).toHaveBeenCalledWith({ + window: { enabled: true, size: 8 }, + query_prompt_template: 'history', + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx new file mode 100644 index 0000000000..0b814b8b25 --- /dev/null +++ b/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx @@ -0,0 +1,514 @@ +/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */ +import type { AssignerNodeOperation, AssignerNodeType } from '../types' +import type { PanelProps } from '@/types/workflow' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { BlockEnum, VarType } from '@/app/components/workflow/types' +import OperationSelector from '../components/operation-selector' +import VarList from '../components/var-list' +import Node from '../node' +import Panel from '../panel' +import { AssignerNodeInputType, WriteMode, writeModeTypesNum } from '../types' +import useConfig from '../use-config' + +const mockHandleAddOperationItem = vi.fn() + +vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ + default: ({ title, operations, children }: any) =>
{title}
{operations}
{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placeholder', () => ({ + default: ({ children }: any) =>
{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ + default: ({ value, onChange, onOpen, placeholder, popupFor, valueTypePlaceHolder, filterVar }: any) => ( +
+
{Array.isArray(value) ? value.join('.') : String(value ?? '')}
+ {valueTypePlaceHolder &&
{`type:${valueTypePlaceHolder}`}
} + {popupFor === 'toAssigned' && ( +
{`filter:${String(filterVar?.({ nodeId: 'node-1', variable: 'count', type: VarType.string }))}:${String(filterVar?.({ nodeId: 'node-2', variable: 'other', type: VarType.string }))}`}
+ )} + +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ value, onChange }: any) => ( +