test(workflow): add unit tests for workflow components (#33910)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star
2026-03-23 16:37:03 +08:00
committed by GitHub
parent abda859075
commit fdc880bc67
54 changed files with 12469 additions and 189 deletions

View File

@ -35,7 +35,7 @@ vi.mock('../ExternalApiSelect', () => ({
<span data-testid="select-value">{value}</span>
<span data-testid="select-items-count">{items.length}</span>
{items.map((item: MockSelectItem) => (
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
<button type="button" key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
{item.name}
</button>
))}

View File

@ -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 (
<MenuContext.Provider value={value}>
<MenuContext value={value}>
{typeof children === 'function' ? children({ open }) : children}
</MenuContext.Provider>
</MenuContext>
)
}
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<HTMLAnchorElement> & { href: string, children?: React.ReactNode }) => (
<a
href={href}
onClick={(event) => {
event.preventDefault()
onClick?.(event)
}}
{...props}
>
{children}
</a>
),
}))
describe('Nav Component', () => {
const mockSetAppDetail = vi.fn()
const mockOnCreate = vi.fn()

View File

@ -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<typeof import('@/app/components/plugins/hooks')>()
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<void>
onHide: () => void
}) => (
<div data-testid="edit-custom-tool-modal">
<button type="button" onClick={() => onAdd({ name: 'collection-a' })}>submit-custom-tool</button>
<button type="button" onClick={onHide}>hide-custom-tool</button>
</div>
),
}))
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<void>
onClose: () => void
}) => (
<div data-testid="install-from-marketplace">
<button type="button" onClick={() => onSuccess()}>complete-featured-install</button>
<button type="button" onClick={onClose}>cancel-featured-install</button>
</div>
),
}))
vi.mock('@/utils/var', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/var')>()
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> = {},
): 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> = {}): ToolValue => ({
provider_name: 'provider-a',
tool_name: 'tool-a',
tool_label: 'Tool A',
...overrides,
})
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'org',
author: 'author',
name: 'Plugin One',
plugin_id: 'plugin-1',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'plugin-1@1.0.0',
icon: 'icon',
verified: true,
label: { en_US: 'Plugin One' },
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<React.ComponentProps<typeof ToolPicker>> = {}) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<ToolPicker
disabled={false}
trigger={<button type="button">open-picker</button>}
isShow={false}
onShowChange={vi.fn()}
onSelect={vi.fn()}
onSelectMultiple={vi.fn()}
selectedTools={[createToolValue()]}
{...props}
/>
</QueryClientProvider>,
)
}
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<typeof useTheme>)
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<typeof useMarketplacePlugins>)
mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType<typeof useAllBuiltInTools>)
mockUseAllCustomTools.mockReturnValue({ data: customTools } as ReturnType<typeof useAllCustomTools>)
mockUseAllWorkflowTools.mockReturnValue({ data: workflowTools } as ReturnType<typeof useAllWorkflowTools>)
mockUseAllMCPTools.mockReturnValue({ data: mcpTools } as ReturnType<typeof useAllMCPTools>)
mockUseInvalidateAllBuiltInTools.mockReturnValue(mockInvalidateBuiltInTools)
mockUseInvalidateAllCustomTools.mockReturnValue(mockInvalidateCustomTools)
mockUseInvalidateAllWorkflowTools.mockReturnValue(mockInvalidateWorkflowTools)
mockUseInvalidateAllMCPTools.mockReturnValue(mockInvalidateMcpTools)
mockUseFeaturedToolsRecommendations.mockReturnValue({
plugins: [],
isLoading: false,
} as ReturnType<typeof useFeaturedToolsRecommendations>)
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<typeof useMarketplacePlugins>)
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<typeof useFeaturedToolsRecommendations>)
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 })
})
})

View File

@ -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 <div>{`dataset-count:${datasetCount}`}</div>
}
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(
<DatasetsDetailProvider nodes={[
{
id: 'node-start',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Start',
desc: '',
type: BlockEnum.Start,
},
} as unknown as Node,
]}
>
<Consumer />
</DatasetsDetailProvider>,
)
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(
<DatasetsDetailProvider nodes={[
createWorkflowNode(['dataset-1', 'dataset-2']),
createWorkflowNode(['dataset-2']),
]}
>
<Consumer />
</DatasetsDetailProvider>,
)
await waitFor(() => {
expect(mockFetchDatasets).toHaveBeenCalledWith({
url: '/datasets',
params: {
page: 1,
ids: ['dataset-1', 'dataset-2'],
},
})
expect(screen.getByText('dataset-count:2')).toBeInTheDocument()
})
})
})

View File

@ -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: () => <div>editing-title</div>,
}))
vi.mock('../scroll-to-selected-node-button', () => ({
default: () => <div>scroll-button</div>,
}))
vi.mock('../env-button', () => ({
default: ({ disabled }: { disabled: boolean }) => <div data-testid="env-button">{`${disabled}`}</div>,
}))
vi.mock('../global-variable-button', () => ({
default: ({ disabled }: { disabled: boolean }) => <div data-testid="global-variable-button">{`${disabled}`}</div>,
}))
vi.mock('../run-and-history', () => ({
default: (props: object) => {
mockRunAndHistory(props)
return <div data-testid="run-and-history" />
},
}))
vi.mock('../version-history-button', () => ({
default: ({ onClick }: { onClick: () => void }) => (
<button type="button" onClick={onClick}>
version-history
</button>
),
}))
vi.mock('../restoring-title', () => ({
default: () => <div>restoring-title</div>,
}))
vi.mock('../running-title', () => ({
default: () => <div>running-title</div>,
}))
vi.mock('../view-history', () => ({
default: (props: { withText?: boolean }) => {
mockViewHistory(props)
return <div data-testid="view-history">{props.withText ? 'with-text' : 'icon-only'}</div>
},
}))
const createSelectedNode = (selected = true) => ({
id: 'node-selected',
data: {
selected,
},
})
const createBackupDraft = (): NonNullable<Shape['backupDraft']> => ({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
environmentVariables: [],
})
const createCurrentVersion = (): NonNullable<Shape['currentVersion']> => ({
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(
<HeaderInNormal
components={{
left: <div>left-slot</div>,
middle: <div>middle-slot</div>,
chatVariableTrigger: <div>chat-trigger</div>,
}}
/>,
{
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(
<HeaderInRestoring />,
{
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(
<HeaderInRestoring onRestoreSettled={onRestoreSettled} />,
{
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(
<HeaderInHistory viewHistoryProps={{ historyUrl: '/history' } as never} />,
{
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,
}))
})
})
})

View File

@ -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: <T,>(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<Record<string, unknown>> }>,
) => {
const DynamicComponent = (props: Record<string, unknown>) => {
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
ReactModule.useEffect(() => {
let mounted = true
loader().then((mod) => {
if (mounted)
setLoaded(() => mod.default)
})
return () => {
mounted = false
}
}, [])
return Loaded ? <Loaded {...props} /> : null
}
return DynamicComponent
},
}
})
vi.mock('../header-in-normal', () => ({
default: () => <div data-testid="header-normal">normal-layout</div>,
}))
vi.mock('../header-in-view-history', () => ({
default: () => <div data-testid="header-history">history-layout</div>,
}))
vi.mock('../header-in-restoring', () => ({
default: () => <div data-testid="header-restoring">restoring-layout</div>,
}))
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(<Header />)
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(<Header />)
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()
})
})

View File

@ -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<typeof import('../store')>('../store')
return {
...actual,
createHooksStore: vi.fn(() => mockStore),
}
})
const Consumer = () => {
const store = useContext(HooksStoreContext)
return <div>{store ? 'has-hooks-store' : 'missing-hooks-store'}</div>
}
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(
<HooksStoreContextProvider>
<Consumer />
</HooksStoreContextProvider>,
)
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(
<HooksStoreContextProvider handleRun={handleRun}>
<Consumer />
</HooksStoreContextProvider>,
)
await waitFor(() => {
expect(mockRefreshAll).toHaveBeenCalledWith({
handleRun,
})
})
})
})

View File

@ -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]: () => <div>start-node-component</div>,
},
PanelComponentMap: {
[BlockEnum.Start]: () => <div>start-panel-component</div>,
},
}))
vi.mock('../_base/node', () => ({
__esModule: true,
default: ({
id,
data,
children,
}: {
id: string
data: { type: BlockEnum }
children: ReactElement
}) => (
<div>
<div>{`base-node:${id}:${data.type}`}</div>
{children}
</div>
),
}))
vi.mock('../_base/components/workflow-panel', () => ({
__esModule: true,
default: ({
id,
data,
children,
}: {
id: string
data: { type: BlockEnum }
children: ReactElement
}) => (
<div>
<div>{`base-panel:${id}:${data.type}`}</div>
{children}
</div>
),
}))
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(
<CustomNode
id="node-1"
data={createNodeData()}
{...baseNodeProps}
/>,
)
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(
<Panel
type={CUSTOM_NODE}
id="node-1"
data={createNodeData()}
/>,
)
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(
<Panel
type="default"
id="node-1"
data={createNodeData()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -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> = {}): 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<typeof useFileUploadConfig>)
mockUseFileSizeLimit.mockReturnValue({
imgSizeLimit: 10 * 1024 * 1024,
docSizeLimit: 20 * 1024 * 1024,
audioSizeLimit: 30 * 1024 * 1024,
videoSizeLimit: 40 * 1024 * 1024,
maxFileUploadLimit: 10,
} as ReturnType<typeof useFileSizeLimit>)
})
describe('FileTypeItem', () => {
it('should render built-in file types and toggle the selected type on click', () => {
const onToggle = vi.fn()
render(
<FileTypeItem
type={SupportUploadFileTypes.image}
selected={false}
onToggle={onToggle}
/>,
)
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(
<FileTypeItem
type={SupportUploadFileTypes.custom}
selected
onToggle={vi.fn()}
customFileTypes={['json']}
onCustomFileTypesChange={onCustomFileTypesChange}
/>,
)
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(
<FileUploadSetting
payload={createPayload()}
isMultiple
onChange={onChange}
/>,
)
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(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.document.name'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_types: [],
}))
rerender(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.custom.name'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_types: [SupportUploadFileTypes.custom],
}))
rerender(
<FileUploadSetting
payload={createPayload({
allowed_file_types: [SupportUploadFileTypes.custom],
})}
isMultiple={false}
onChange={onChange}
/>,
)
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(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.both'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
}))
rerender(
<FileUploadSetting
payload={createPayload({
allowed_file_types: [SupportUploadFileTypes.custom],
})}
isMultiple={false}
onChange={onChange}
/>,
)
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(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
inFeaturePanel
onChange={vi.fn()}
/>,
)
expect(screen.getByText('appDebug.variableConfig.file.supportFileTypes')).toBeInTheDocument()
rerender(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
inFeaturePanel
hideSupportFileType
onChange={vi.fn()}
/>,
)
expect(screen.queryByText('appDebug.variableConfig.file.document.name')).not.toBeInTheDocument()
})
})
})

View File

@ -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<typeof import('@/context/i18n')>()
return {
...actual,
useDocLink: () => mockDocLink,
}
})
vi.mock('../hooks', () => ({
useDefaultValue: vi.fn(),
useErrorHandle: vi.fn(),
}))
vi.mock('../../node-handle', () => ({
NodeSourceHandle: ({ handleId }: { handleId: string }) => <div className="react-flow__handle" data-handleid={handleId} />,
}))
const mockUseDefaultValue = vi.mocked(useDefaultValue)
const mockUseErrorHandle = vi.mocked(useErrorHandle)
const originalDOMMatrixReadOnly = window.DOMMatrixReadOnly
const baseData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
title: 'Code',
desc: '',
type: 'code' as CommonNodeType['type'],
...overrides,
})
const ErrorHandleNodeHarness = ({ id, data }: NodeProps<CommonNodeType>) => (
<ErrorHandleOnNode id={id} data={data} />
)
const renderErrorHandleNode = (data: CommonNodeType) =>
renderWorkflowFlowComponent(<div />, {
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(<FailBranchCard />)
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(
<DefaultValue
forms={[
{ key: 'message', type: VarType.string, value: 'hello' },
{ key: 'items', type: VarType.arrayString, value: '["a"]' },
]}
onFormChange={onFormChange}
/>,
)
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(
<ErrorHandleTypeSelector
value={ErrorHandleTypeEnum.none}
onSelected={onSelected}
/>,
)
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(<ErrorHandleTip />)
expect(container).toBeEmptyDOMElement()
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.failBranch} />)
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.inLog')).toBeInTheDocument()
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.defaultValue} />)
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(
<ErrorHandleOnPanel
id="node-1"
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
/>,
)
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(
<ErrorHandleOnPanel
id="node-1"
data={baseData({
error_strategy: ErrorHandleTypeEnum.defaultValue,
default_value: [{ key: 'answer', type: VarType.string, value: 'draft' }],
})}
/>,
)
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(
<ErrorHandleOnPanel
id="node-1"
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
/>,
)
expect(screen.queryByText('workflow.nodes.common.errorHandle.failBranch.customize')).not.toBeInTheDocument()
})
it('should render the default-value node badge', () => {
renderWorkflowFlowComponent(
<ErrorHandleOnNode
id="node-1"
data={baseData({
error_strategy: ErrorHandleTypeEnum.defaultValue,
})}
/>,
{
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)
})
})
})
})

View File

@ -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(<Add />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})
})

View File

@ -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(
<div>
<Box withBorderBottom className="box-test">Box content</Box>
<Group withBorderBottom className="group-test">Group content</Group>
</div>,
)
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(
<div>
<BoxGroup>Inside box group</BoxGroup>
<GroupField
fieldProps={{
fieldTitleProps: {
title: 'Grouped field',
},
}}
>
Group field body
</GroupField>
</div>,
)
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(
<BoxGroupField
@ -25,10 +59,23 @@ describe('layout index', () => {
expect(screen.getByText('Body content')).toBeInTheDocument()
})
it('should render FieldTitle from the barrel export', () => {
render(<FieldTitle title="Advanced" subTitle="Extra details" />)
it('should collapse and expand Field children when supportCollapse is enabled', async () => {
const user = userEvent.setup()
render(
<Field
supportCollapse
fieldTitleProps={{ title: 'Advanced' }}
>
<div>Extra details</div>
</Field>,
)
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()
})
})

View File

@ -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 (
<div data-testid="prompt-editor">
<div data-testid="editable-flag">{editable ? 'editable' : 'readonly'}</div>
<div data-testid="value-flag">{value || 'empty'}</div>
<button type="button" onClick={() => onChange?.('updated text')}>trigger-change</button>
</div>
)
},
}))
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(
<MixedVariableTextInput
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
/>,
)
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(
<MixedVariableTextInput
readOnly
value="seed value"
onChange={onChange}
/>,
)
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')
})
})

View File

@ -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(
<div onClick={parentClick}>
<Placeholder />
</div>,
)
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(<Placeholder />)
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)
})
})

View File

@ -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) => (
<div>
<div>{trigger()}</div>
<div>{`available:${(availableBlocksTypes || []).join(',')}`}</div>
<div>{`show-start:${String(showStartTab)}`}</div>
<div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div>
<div>{`force-start:${String(forceEnableStartTab)}`}</div>
<div>{`allow-start:${String(allowUserInputSelection)}`}</div>
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button>
</div>
),
}))
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
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<typeof useAvailableBlocks>)
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<typeof useNodeMetaData>)
mockUseNodesInteractions.mockReturnValue({
handleNodeChange,
handleNodeDelete,
handleNodesDuplicate,
handleNodeSelect,
handleNodesCopy,
} as unknown as ReturnType<typeof useNodesInteractions>)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>)
mockUseNodesSyncDraft.mockReturnValue({
doSyncWorkflowDraft: vi.fn(),
handleSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: vi.fn(),
} as ReturnType<typeof useNodesSyncDraft>)
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(
<ChangeBlock
nodeId="node-1"
nodeData={{ type: BlockEnum.Code } as any}
sourceHandle="source"
/>,
)
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<typeof useAvailableBlocks>)
mockUseIsChatMode.mockReturnValueOnce(true)
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
mockUseNodes.mockReturnValueOnce([] as any)
const { rerender } = render(
<ChangeBlock
nodeId="trigger-node"
nodeData={{ type: BlockEnum.TriggerWebhook } as any}
sourceHandle="source"
/>,
)
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<typeof useAvailableBlocks>)
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(
<ChangeBlock
nodeId="start-node"
nodeData={{ type: BlockEnum.Start } as any}
sourceHandle="source"
/>,
)
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(
<PanelOperatorPopup
id="node-1"
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '' } as any}
onClosePopup={vi.fn()}
showHelpLink
/>,
{
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(
<PanelOperatorPopup
id="node-2"
data={{ type: BlockEnum.Tool, title: 'Workflow Tool', desc: '', provider_type: 'workflow', provider_id: 'workflow-tool' } as any}
onClosePopup={vi.fn()}
showHelpLink={false}
/>,
{
nodes: [],
edges: [],
},
)
expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow')
mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType<typeof useNodesReadOnly>)
mockUseNodeMetaData.mockReturnValueOnce({
isTypeFixed: true,
isSingleton: true,
isUndeletable: true,
description: 'Read only node',
author: 'Dify',
} as ReturnType<typeof useNodeMetaData>)
rerender(
<PanelOperatorPopup
id="node-3"
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
onClosePopup={vi.fn()}
showHelpLink={false}
/>,
)
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
})

View File

@ -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(<SupportVarInput value={'Hello {{user_name}}\nWorld'} />)
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(
<SupportVarInput
isFocus
value="draft"
onFocus={onFocus}
>
<input aria-label="inline-editor" />
</SupportVarInput>,
)
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(
<SupportVarInput
isFocus
readonly
value="readonly content"
>
<input aria-label="hidden-editor" />
</SupportVarInput>,
)
expect(screen.queryByRole('textbox', { name: 'hidden-editor' })).not.toBeInTheDocument()
expect(screen.getByTitle('readonly content')).toBeInTheDocument()
})
})

View File

@ -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 <div data-testid="var-reference-vars">{vars.length}</div>
},
}))
const createOutputVar = (overrides: Partial<NodeOutPutVar> = {}): 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(
<AssignedVarReferencePopup
vars={[]}
onChange={vi.fn()}
/>,
)
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(
<AssignedVarReferencePopup
vars={[createOutputVar()]}
itemWidth={280}
onChange={onChange}
/>,
)
expect(screen.getByTestId('var-reference-vars')).toHaveTextContent('1')
expect(mockVarReferenceVars).toHaveBeenCalledWith({
vars: [createOutputVar()],
onChange,
itemWidth: 280,
isSupportFileVar: true,
})
})
})

View File

@ -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(
<VariableLabelInSelect
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['source-node', 'payload', 'answer']}
/>,
)
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(
<VariableLabelInEditor
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['source-node', 'payload']}
isSelected
errorMsg="Invalid variable"
rightSlot={<span>suffix</span>}
/>,
)
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(
<div>
<VariableIcon variables={['env', 'API_KEY']} />
<VariableIconWithColor
variables={['conversation', 'message']}
isExceptionVariable
/>
</div>,
)
expect(container.querySelectorAll('svg').length).toBeGreaterThan(0)
})
it('should render the base variable name with shortened path and title', () => {
render(
<VariableName
variables={['node-id', 'payload', 'answer']}
notShowFullPath
/>,
)
expect(screen.getByText('answer')).toHaveAttribute('title', 'answer')
})
it('should render the base node label only when node type exists', () => {
const { container, rerender } = render(<VariableNodeLabel />)
expect(container).toBeEmptyDOMElement()
rerender(
<VariableNodeLabel
nodeType={BlockEnum.Code}
nodeTitle="Code Node"
/>,
)
expect(screen.getByText('Code Node')).toBeInTheDocument()
})
it('should render the base label with variable type and right slot', () => {
render(
<VariableLabel
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['sys', 'query']}
variableType={VarType.string}
rightSlot={<span>slot</span>}
/>,
)
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('query')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('slot')).toBeInTheDocument()
})
})
})

View File

@ -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<any> | undefined = []
let mockCustomTools: Array<any> | undefined = []
let mockWorkflowTools: Array<any> | undefined = []
let mockMcpTools: Array<any> | undefined = []
let mockMarketplaceIcon: string | Record<string, string> | 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) => (
<div>{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}:{modelList.length}</div>
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: any) => <div>{`indicator:${color}`}</div>,
}))
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) => <div>{`app-icon:${background}:${icon}`}</div>,
}))
vi.mock('@/app/components/base/icons/src/vender/other', () => ({
Group: () => <div>group-icon</div>,
}))
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) => <div><div>{label}</div>{children}</div>,
GroupLabel: ({ className, children }: any) => <div className={className}>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/setting-item', () => ({
SettingItem: ({ label, status, tooltip, children }: any) => <div>{label}:{status}:{tooltip}:{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, children }: any) => <div><div>{title}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/agent-strategy', () => ({
AgentStrategy: ({ onStrategyChange }: any) => (
<button
type="button"
onClick={() => onStrategyChange({
agent_strategy_provider_name: 'provider/updated',
agent_strategy_name: 'updated-strategy',
agent_strategy_label: 'Updated Strategy',
agent_output_schema: { properties: { extra: { type: 'string', description: 'extra output' } } },
plugin_unique_identifier: 'provider/updated:1.0.0',
meta: { version: '2.0.0' },
})}
>
change-strategy
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
MCPToolAvailabilityProvider: ({ children }: any) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({
default: ({ onChange }: any) => <button type="button" onClick={() => onChange({ window: { enabled: true, size: 8 }, query_prompt_template: 'history' })}>change-memory</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type, description }: any) => <div>{`${name}:${type}:${description}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
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> = {}): 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<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
setInputs: vi.fn(),
handleVarListChange: vi.fn(),
handleAddVariable: vi.fn(),
currentStrategy: {
identity: {
author: 'provider',
name: 'react',
icon: 'icon',
label: { en_US: 'React Agent' } as 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(<ModelBar />)
expect(container).toHaveTextContent('no-model:0')
expect(screen.getByText('indicator:red')).toBeInTheDocument()
rerender(<ModelBar provider="openai" model="gpt-4o" />)
expect(container).toHaveTextContent('openai/gpt-4o:1')
expect(screen.queryByText('indicator:red')).not.toBeInTheDocument()
rerender(<ModelBar provider="openai" model="gpt-4.1" />)
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(<ToolIcon id="tool-0" providerName="author/tool-a" />)
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(<ToolIcon id="tool-1" providerName="author/tool-b" />)
expect(screen.getByText('app-icon:#fff:B')).toBeInTheDocument()
expect(screen.getByText('indicator:yellow')).toBeInTheDocument()
mockBuiltInTools = undefined
secondRender.rerender(<ToolIcon id="tool-2" providerName="author/tool-c" />)
expect(screen.getByText('group-icon')).toBeInTheDocument()
mockBuiltInTools = []
secondRender.rerender(<ToolIcon id="tool-3" providerName="market/tool-d" />)
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(
<Node
id="agent-node"
data={createData()}
/>,
)
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(
<Panel
id="agent-node"
data={createData()}
panelProps={panelProps}
/>,
)
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',
})
})
})
})

View File

@ -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) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placeholder', () => ({
default: ({ children }: any) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ value, onChange, onOpen, placeholder, popupFor, valueTypePlaceHolder, filterVar }: any) => (
<div>
<div>{Array.isArray(value) ? value.join('.') : String(value ?? '')}</div>
{valueTypePlaceHolder && <div>{`type:${valueTypePlaceHolder}`}</div>}
{popupFor === 'toAssigned' && (
<div>{`filter:${String(filterVar?.({ nodeId: 'node-1', variable: 'count', type: VarType.string }))}:${String(filterVar?.({ nodeId: 'node-2', variable: 'other', type: VarType.string }))}`}</div>
)}
<button
type="button"
onClick={() => {
onOpen?.()
onChange(popupFor === 'assigned' ? ['node-1', 'count'] : ['node-2', 'result'])
}}
>
{placeholder || popupFor || 'pick-var'}
</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value, onChange }: any) => (
<textarea
aria-label="code-editor"
value={value}
onChange={event => onChange(event.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/bool-value', () => ({
default: ({ value, onChange }: any) => (
<button type="button" onClick={() => onChange(!value)}>
{`bool:${String(value)}`}
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInNode: ({ variables, nodeTitle, rightSlot }: any) => (
<div>
<span>{nodeTitle}</span>
<span>{variables.join('.')}</span>
{rightSlot}
</div>
),
}))
vi.mock('../hooks', () => ({
useHandleAddOperationItem: () => mockHandleAddOperationItem,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({
variable_selector: ['node-1', 'count'],
input_type: AssignerNodeInputType.variable,
operation: WriteMode.overwrite,
value: ['node-2', 'result'],
...overrides,
})
const createData = (overrides: Partial<AssignerNodeType> = {}): AssignerNodeType => ({
title: 'Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
version: '2',
items: [createOperation()],
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
handleOperationListChanges: vi.fn(),
getAssignedVarType: vi.fn(() => VarType.string),
getToAssignedVarType: vi.fn(() => VarType.string),
writeModeTypes: [WriteMode.overwrite, WriteMode.clear, WriteMode.set],
writeModeTypesArr: [WriteMode.overwrite, WriteMode.clear, WriteMode.append, WriteMode.extend],
writeModeTypesNum,
filterAssignedVar: vi.fn(() => true),
filterToAssignedVar: vi.fn(() => true),
getAvailableVars: vi.fn(() => []),
filterVar: vi.fn(() => vi.fn(() => true)),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('assigner path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHandleAddOperationItem.mockReturnValue([createOperation(), createOperation({ variable_selector: [] })])
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Path Integration', () => {
it('should open the operation selector and choose number operations', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<OperationSelector
value={WriteMode.overwrite}
onSelect={onSelect}
assignedVarType={VarType.number}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={[WriteMode.increment]}
/>,
)
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
expect(screen.getByText('workflow.nodes.assigner.operations.clear')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.set')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.+=')).toBeInTheDocument()
await user.click(screen.getByText('workflow.nodes.assigner.operations.+='))
expect(onSelect).toHaveBeenCalledWith({ value: WriteMode.increment, name: WriteMode.increment })
})
it('should not open a disabled operation selector', async () => {
const user = userEvent.setup()
render(
<OperationSelector
value={WriteMode.overwrite}
onSelect={vi.fn()}
disabled
assignedVarType={VarType.string}
writeModeTypes={[WriteMode.overwrite]}
/>,
)
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
expect(screen.queryByText('workflow.nodes.assigner.operations.title')).not.toBeInTheDocument()
})
it('should render empty and populated variable lists across constant editors', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onOpen = vi.fn()
const { rerender } = render(
<VarList
readonly={false}
nodeId="node-1"
list={[]}
onChange={onChange}
/>,
)
expect(screen.getByText('workflow.nodes.assigner.noVarTip')).toBeInTheDocument()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ variable_selector: [], value: [] })]}
onChange={onChange}
onOpen={onOpen}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.string)}
getToAssignedVarType={vi.fn(() => VarType.string)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
await user.click(screen.getByText('workflow.nodes.assigner.selectAssignedVariable'))
expect(onOpen).toHaveBeenCalledWith(0)
expect(onChange).toHaveBeenLastCalledWith([
{
variable_selector: ['node-1', 'count'],
operation: WriteMode.overwrite,
input_type: AssignerNodeInputType.variable,
value: undefined,
},
], ['node-1', 'count'])
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.overwrite, value: ['node-2', 'result'] })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.boolean)}
getToAssignedVarType={vi.fn(() => VarType.string)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
expect(screen.getByText('filter:false:true')).toBeInTheDocument()
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
await user.click(screen.getByText('workflow.nodes.assigner.operations.set'))
expect(onChange).toHaveBeenLastCalledWith([
createOperation({
operation: WriteMode.set,
input_type: AssignerNodeInputType.constant,
value: false,
}),
])
onChange.mockClear()
await user.click(screen.getByText('workflow.nodes.assigner.setParameter'))
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.overwrite, value: ['node-2', 'result'] }),
], ['node-2', 'result'])
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: 'hello' })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.string)}
getToAssignedVarType={vi.fn(() => VarType.string)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated text' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: 'updated text' }),
], 'updated text')
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: 3 })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.number)}
getToAssignedVarType={vi.fn(() => VarType.number)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByDisplayValue('3'), { target: { value: '5' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: 5 }),
], 5)
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: false })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.boolean)}
getToAssignedVarType={vi.fn(() => VarType.boolean)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
await user.click(screen.getByRole('button', { name: 'bool:false' }))
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: true }),
], true)
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: '{\"a\":1}' })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.object)}
getToAssignedVarType={vi.fn(() => VarType.object)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByLabelText('code-editor'), { target: { value: '{\"a\":2}' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: '{\"a\":2}' }),
], '{"a":2}')
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.increment, value: 2 })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.number)}
getToAssignedVarType={vi.fn(() => VarType.number)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '4' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.increment, value: 4 }),
], 4)
const buttons = screen.getAllByRole('button')
await user.click(buttons.at(-1)!)
expect(onChange).toHaveBeenLastCalledWith([])
})
it('should render version 2 and legacy node previews', () => {
const { rerender } = renderWorkflowFlowComponent(
<Node
id="assigner-node"
data={createData({
items: [createOperation({ variable_selector: [] })],
})}
/>,
{
nodes: [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
],
edges: [],
},
)
expect(screen.getByText('workflow.nodes.assigner.varNotSet')).toBeInTheDocument()
rerender(
<Node
id="assigner-node"
data={createData({
items: [createOperation()],
})}
/>,
)
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(screen.getByText('node-1.count')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.over-write')).toBeInTheDocument()
rerender(
<Node
id="assigner-node"
data={{
title: 'Legacy Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
assigned_variable_selector: ['sys', 'query'],
write_mode: WriteMode.append,
} as any}
/>,
)
expect(screen.getByText('Start')).toBeInTheDocument()
expect(screen.getByText('sys.query')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.append')).toBeInTheDocument()
})
it('should skip empty version 2 items and resolve system variables without an operation badge', () => {
renderWorkflowFlowComponent(
<Node
id="assigner-node"
data={createData({
items: [
createOperation({ variable_selector: [] }),
createOperation({
variable_selector: ['sys', 'query'],
operation: undefined,
}),
],
})}
/>,
{
nodes: [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
],
edges: [],
},
)
expect(screen.getByText('Start')).toBeInTheDocument()
expect(screen.getByText('sys.query')).toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.assigner.operations.over-write')).not.toBeInTheDocument()
})
it('should return null for legacy nodes without assigned variables and resolve non-system legacy vars', () => {
const { rerender } = renderWorkflowFlowComponent(
<Node
id="assigner-node"
data={{
title: 'Legacy Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
assigned_variable_selector: [],
write_mode: WriteMode.append,
} as any}
/>,
{
nodes: [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
],
edges: [],
},
)
expect(screen.queryByText('workflow.nodes.assigner.operations.append')).not.toBeInTheDocument()
expect(screen.queryByText('node-1.count')).not.toBeInTheDocument()
rerender(
<Node
id="assigner-node"
data={{
title: 'Legacy Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
assigned_variable_selector: ['node-1', 'count'],
write_mode: WriteMode.append,
} as any}
/>,
)
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(screen.getByText('node-1.count')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.append')).toBeInTheDocument()
})
it('should add panel operations with the real variable list inside the panel', async () => {
const user = userEvent.setup()
const config = createConfigResult({
inputs: createData(),
})
mockUseConfig.mockReturnValue(config)
render(
<Panel
id="assigner-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getAllByRole('button')[0])
expect(mockHandleAddOperationItem).toHaveBeenCalledWith(createData().items)
expect(config.handleOperationListChanges).toHaveBeenCalledWith([
createOperation(),
createOperation({ variable_selector: [] }),
])
expect(screen.getByText('workflow.nodes.assigner.variables')).toBeInTheDocument()
expect(screen.getByText('node-1.count')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,39 @@
import type { CodeDependency } from '../types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DependencyPicker from '../dependency-picker'
const dependencies: CodeDependency[] = [
{ name: 'numpy', version: '1.0.0' },
{ name: 'pandas', version: '2.0.0' },
]
describe('DependencyPicker', () => {
it('should open the dependency list, filter by search text, and select a new dependency', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<DependencyPicker
value={dependencies[0]!}
available_dependencies={dependencies}
onChange={onChange}
/>,
)
expect(screen.getByText('numpy')).toBeInTheDocument()
await user.click(screen.getByText('numpy'))
await user.type(screen.getByRole('textbox'), 'pan')
expect(screen.getByRole('textbox')).toHaveValue('pan')
expect(screen.getByText('pandas')).toBeInTheDocument()
await user.click(screen.getByText('pandas'))
expect(onChange).toHaveBeenCalledWith(dependencies[1])
await waitFor(() => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,204 @@
import type { ReactNode } from 'react'
import type { DocExtractorNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LanguagesSupported } from '@/i18n-config/language'
import { BlockEnum } from '../../../types'
import Node from '../node'
import Panel from '../panel'
import useConfig from '../use-config'
let mockLocale = 'en-US'
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useNodes: () => [
{
id: 'node-1',
data: {
title: 'Input Files',
type: BlockEnum.Start,
},
},
],
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInNode: ({
variables,
nodeTitle,
nodeType,
}: {
variables: string[]
nodeTitle?: string
nodeType?: BlockEnum
}) => <div>{`${nodeTitle}:${nodeType}:${variables.join('.')}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
__esModule: true,
default: ({ title, children }: { title: ReactNode, children: ReactNode }) => (
<div>
<div>{title}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
__esModule: true,
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
__esModule: true,
default: ({
onChange,
}: {
onChange: (value: string[]) => void
}) => <button type="button" onClick={() => onChange(['node-1', 'files'])}>pick-file-var</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-help-link', () => ({
useNodeHelpLink: () => 'https://docs.example.com/document-extractor',
}))
vi.mock('@/service/use-common', () => ({
useFileSupportTypes: () => ({
data: {
allowed_extensions: ['PDF', 'md', 'md', 'DOCX'],
},
}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale,
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<DocExtractorNodeType> = {}): DocExtractorNodeType => ({
title: 'Document Extractor',
desc: '',
type: BlockEnum.DocExtractor,
variable_selector: ['node-1', 'files'],
is_array_file: false,
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
handleVarChanges: vi.fn(),
filterVar: () => true,
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('document-extractor path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLocale = 'en-US'
mockUseConfig.mockReturnValue(createConfigResult())
})
it('should render nothing when the node input variable is not configured', () => {
const { container } = render(
<Node
id="doc-node"
data={createData({
variable_selector: [],
})}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should render the selected input variable on the node', () => {
render(
<Node
id="doc-node"
data={createData()}
/>,
)
expect(screen.getByText('workflow.nodes.docExtractor.inputVar')).toBeInTheDocument()
expect(screen.getByText('Input Files:start:node-1.files')).toBeInTheDocument()
})
it('should wire panel input changes and format supported file types for english locales', async () => {
const user = userEvent.setup()
const handleVarChanges = vi.fn()
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_array_file: false,
}),
handleVarChanges,
}))
render(
<Panel
id="doc-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-file-var' }))
expect(handleVarChanges).toHaveBeenCalledWith(['node-1', 'files'])
expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf, markdown, docx"}')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'workflow.nodes.docExtractor.learnMore' })).toHaveAttribute(
'href',
'https://docs.example.com/document-extractor',
)
expect(screen.getByText('text:string')).toBeInTheDocument()
})
it('should use chinese separators and array output types when the input is an array of files', () => {
mockLocale = LanguagesSupported[1]
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_array_file: true,
}),
}))
render(
<Panel
id="doc-node"
data={createData({
is_array_file: true,
})}
panelProps={panelProps}
/>,
)
expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf、 markdown、 docx"}')).toBeInTheDocument()
expect(screen.getByText('text:array[string]')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,705 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { KeyValue as HttpKeyValue, HttpNodeType } 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 { useStore } from '@/app/components/workflow/store'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import ApiInput from '../components/api-input'
import AuthorizationModal from '../components/authorization'
import RadioGroup from '../components/authorization/radio-group'
import EditBody from '../components/edit-body'
import KeyValue from '../components/key-value'
import BulkEdit from '../components/key-value/bulk-edit'
import KeyValueEdit from '../components/key-value/key-value-edit'
import InputItem from '../components/key-value/key-value-edit/input-item'
import KeyValueItem from '../components/key-value/key-value-edit/item'
import Timeout from '../components/timeout'
import Node from '../node'
import Panel from '../panel'
import { AuthorizationType, BodyType, Method } from '../types'
import useConfig from '../use-config'
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
default: vi.fn((_nodeId: string, options?: any) => ({
availableVars: [
{ variable: ['node-1', 'token'], type: VarType.string },
{ variable: ['node-1', 'upload'], type: VarType.file },
].filter(varPayload => options?.filterVar ? options.filterVar(varPayload) : true),
availableNodes: [],
availableNodesWithParent: [],
})),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({
default: ({ value, onChange, placeholder, className, readOnly, onFocusChange }: any) => (
<input
value={value}
placeholder={placeholder}
className={className}
readOnly={readOnly}
onFocus={() => onFocusChange?.(true)}
onBlur={() => onFocusChange?.(false)}
onChange={event => onChange(event.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type }: any) => <div>{name}:{type}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange, filterVar, onRemove }: any) => (
<div>
<div>{`file-filter:${String(filterVar?.({ type: VarType.file }))}:${String(filterVar?.({ type: VarType.string }))}`}</div>
<button type="button" onClick={() => onChange(['node-1', 'file'])}>pick-file</button>
{onRemove && <button type="button" onClick={onRemove}>remove-file</button>}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
default: ({ value, onChange, title }: any) => (
<div>
<div>{typeof title === 'string' ? title : 'editor'}</div>
<input value={value} onChange={event => onChange(event.target.value)} />
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/text-editor', () => ({
default: ({ value, onChange, onBlur, headerRight }: any) => (
<div>
{headerRight}
<textarea value={value} onChange={event => onChange(event.target.value)} onBlur={onBlur} />
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/readonly-input-with-select-var', () => ({
default: ({ value }: any) => <div>{value}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/selector', () => ({
default: ({ options, onChange, trigger }: any) => (
<div>
{trigger}
{options.map((option: any) => (
<button key={option.value} type="button" onClick={() => onChange(option.value)}>
{option.label}
</button>
))}
</div>
),
}))
vi.mock('../components/curl-panel', () => ({
default: () => <div>curl-panel</div>,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const mockUseStore = vi.mocked(useStore)
const createData = (overrides: Partial<HttpNodeType> = {}): HttpNodeType => ({
title: 'HTTP Request',
desc: '',
type: BlockEnum.HttpRequest,
variables: [],
method: Method.get,
url: 'https://api.example.com',
authorization: { type: AuthorizationType.none },
headers: '',
params: '',
body: { type: BodyType.none, data: [] },
timeout: { connect: 5, read: 10, write: 15 },
ssl_verify: true,
...overrides,
})
const keyValueItem: HttpKeyValue = {
id: 'kv-1',
key: 'name',
value: 'alice',
type: 'text',
}
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
isDataReady: true,
inputs: createData(),
handleVarListChange: vi.fn(),
handleAddVariable: vi.fn(),
filterVar: vi.fn(() => true),
handleMethodChange: vi.fn(),
handleUrlChange: vi.fn(),
headers: [keyValueItem],
setHeaders: vi.fn(),
addHeader: vi.fn(),
isHeaderKeyValueEdit: false,
toggleIsHeaderKeyValueEdit: vi.fn(),
params: [keyValueItem],
setParams: vi.fn(),
addParam: vi.fn(),
isParamKeyValueEdit: false,
toggleIsParamKeyValueEdit: vi.fn(),
setBody: vi.fn(),
handleSSLVerifyChange: vi.fn(),
isShowAuthorization: true,
showAuthorization: vi.fn(),
hideAuthorization: vi.fn(),
setAuthorization: vi.fn(),
setTimeout: vi.fn(),
isShowCurlPanel: true,
showCurlPanel: vi.fn(),
hideCurlPanel: vi.fn(),
handleCurlImport: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
const renderPanel = (data: HttpNodeType = createData()) => (
render(<Panel id="node-1" data={data} panelProps={panelProps} />)
)
describe('http path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseStore.mockReturnValue({
HttpRequest: {
timeout: {
max_connect_timeout: 10,
max_read_timeout: 600,
max_write_timeout: 600,
},
},
} as any)
mockUseConfig.mockReturnValue(createConfigResult())
})
// The HTTP path should expose auth, request editing, key-value tables, timeout, and request preview behavior.
describe('Path Integration', () => {
it('should switch radio-group options', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<RadioGroup
options={[
{ value: 'none', label: 'None' },
{ value: 'apiKey', label: 'API Key' },
]}
value="none"
onChange={onChange}
/>,
)
await user.click(screen.getByText('API Key'))
expect(onChange).toHaveBeenCalledWith('apiKey')
})
it('should edit authorization settings and save them', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onHide = vi.fn()
render(
<AuthorizationModal
nodeId="node-1"
payload={{ type: 'apiKey', config: { type: 'custom', header: 'X-Key', api_key: 'secret' } } as any}
onChange={onChange}
isShow
onHide={onHide}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.authorization.api-key'))
await user.click(screen.getByText('workflow.nodes.http.authorization.custom'))
fireEvent.change(screen.getByDisplayValue('secret'), { target: { value: 'updated-secret' } })
await user.click(screen.getByText('common.operation.save'))
expect(onChange).toHaveBeenCalled()
expect(onHide).toHaveBeenCalled()
})
it('should bootstrap api key config when auth starts without config', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<AuthorizationModal
nodeId="node-1"
payload={{ type: 'none' as any }}
onChange={onChange}
isShow
onHide={vi.fn()}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.authorization.api-key'))
await user.click(screen.getByText('common.operation.save'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'api-key',
config: expect.objectContaining({
type: 'basic',
api_key: '',
}),
}))
})
it('should create custom header auth config and apply focus styles to the api key input', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<AuthorizationModal
nodeId="node-1"
payload={{ type: 'api-key' as any }}
onChange={onChange}
isShow
onHide={vi.fn()}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.authorization.custom'))
const inputs = screen.getAllByRole('textbox')
fireEvent.change(inputs[0] as HTMLInputElement, { target: { value: 'X-Token' } })
fireEvent.focus(inputs[1] as HTMLInputElement)
expect(inputs[1]).toHaveClass('border-components-input-border-active')
fireEvent.change(inputs[1] as HTMLInputElement, { target: { value: 'secret-token' } })
fireEvent.blur(inputs[1] as HTMLInputElement)
await user.click(screen.getByText('common.operation.save'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'api-key',
config: expect.objectContaining({
type: 'custom',
header: 'X-Token',
api_key: 'secret-token',
}),
}))
})
it('should update method and url from the api input', async () => {
const user = userEvent.setup()
const onMethodChange = vi.fn()
const onUrlChange = vi.fn()
render(
<ApiInput
nodeId="node-1"
readonly={false}
method={'GET' as any}
onMethodChange={onMethodChange}
url="https://api.example.com"
onUrlChange={onUrlChange}
/>,
)
await user.click(screen.getByText('POST'))
fireEvent.change(screen.getByDisplayValue('https://api.example.com'), { target: { value: 'https://api.changed.com' } })
expect(onMethodChange).toHaveBeenCalled()
expect(onUrlChange).toHaveBeenCalledWith('https://api.changed.com')
})
it('should hide the method dropdown icon and use an empty placeholder in readonly mode', () => {
const { container } = render(
<ApiInput
nodeId="node-1"
readonly
method={'GET' as any}
onMethodChange={vi.fn()}
url="https://api.example.com"
onUrlChange={vi.fn()}
/>,
)
expect(container.querySelector('svg')).toBeNull()
expect(screen.getByDisplayValue('https://api.example.com')).toHaveAttribute('placeholder', '')
})
it('should update focus styling for editable inputs and show the remove action again on blur', () => {
const onChange = vi.fn()
const onRemove = vi.fn()
const { container, rerender } = render(
<InputItem
nodeId="node-1"
value="alice"
onChange={onChange}
hasRemove
onRemove={onRemove}
/>,
)
const input = screen.getByDisplayValue('alice')
fireEvent.focus(input)
expect(input).toHaveClass('bg-components-input-bg-active')
expect(container.querySelector('button')).toBeNull()
fireEvent.blur(input)
expect(container.querySelector('button')).not.toBeNull()
rerender(
<InputItem
nodeId="node-1"
value=""
onChange={onChange}
hasRemove={false}
placeholder="missing-value"
readOnly
/>,
)
expect(screen.getByText('missing-value')).toBeInTheDocument()
})
it('should clamp timeout values and propagate changes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<Timeout
readonly={false}
nodeId="node-1"
payload={{ connect: 5, read: 10, write: 15 }}
onChange={onChange}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.timeout.title'))
fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '999' } })
expect(onChange).toHaveBeenCalled()
})
it('should clear timeout values to undefined and clamp low values to the minimum', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<Timeout
readonly={false}
nodeId="node-1"
payload={{ connect: 5, read: 10, write: 15 }}
onChange={onChange}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.timeout.title'))
fireEvent.change(screen.getByDisplayValue('10'), { target: { value: '' } })
fireEvent.change(screen.getByDisplayValue('15'), { target: { value: '0' } })
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ read: undefined }))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ write: 1 }))
})
it('should delegate key-value list editing and bulk editing actions', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onAdd = vi.fn()
render(
<div>
<KeyValue
readonly={false}
nodeId="node-1"
list={[keyValueItem]}
onChange={onChange}
onAdd={onAdd}
/>
<BulkEdit
value="name:alice"
onChange={onChange}
onSwitchToKeyValueEdit={onAdd}
/>
</div>,
)
fireEvent.change(screen.getAllByDisplayValue('name:alice')[0], { target: { value: 'name:bob' } })
fireEvent.blur(screen.getAllByDisplayValue('name:bob')[0])
await user.click(screen.getByText('workflow.nodes.http.keyValueEdit'))
expect(onChange).toHaveBeenCalled()
expect(onAdd).toHaveBeenCalled()
})
it('should return null when key-value edit receives a non-array list', () => {
const { container } = render(
<KeyValueEdit
readonly={false}
nodeId="node-1"
list={'invalid' as any}
onChange={vi.fn()}
onAdd={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should edit standalone input items and key-value rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onRemove = vi.fn()
const onAdd = vi.fn()
render(
<div>
<InputItem
nodeId="node-1"
value="alice"
onChange={onChange}
hasRemove
onRemove={onRemove}
/>
<KeyValueItem
instanceId="kv-1"
nodeId="node-1"
readonly={false}
canRemove
payload={keyValueItem}
onChange={onChange}
onRemove={onRemove}
isLastItem
onAdd={onAdd}
isSupportFile
/>
<KeyValueEdit
readonly={false}
nodeId="node-1"
list={[keyValueItem]}
onChange={onChange}
onAdd={onAdd}
/>
</div>,
)
fireEvent.change(screen.getAllByDisplayValue('alice')[0], { target: { value: 'bob' } })
await user.click(screen.getByText('text'))
await user.click(screen.getByText('file'))
expect(onChange).toHaveBeenCalled()
})
it('should edit key-only rows and select file payload rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onRemove = vi.fn()
render(
<KeyValueItem
instanceId="kv-2"
nodeId="node-1"
readonly={false}
canRemove
payload={{ id: 'kv-2', key: 'attachment', value: '', type: 'file', file: [] } as any}
onChange={onChange}
onRemove={onRemove}
isLastItem={false}
onAdd={vi.fn()}
isSupportFile
keyNotSupportVar
/>,
)
fireEvent.change(screen.getByDisplayValue('attachment'), { target: { value: 'upload' } })
expect(screen.getByText('file-filter:true:false')).toBeInTheDocument()
await user.click(screen.getByText('pick-file'))
await user.click(screen.getByText('remove-file'))
expect(onChange).toHaveBeenCalled()
expect(onRemove).toHaveBeenCalled()
})
it('should update the raw-text body payload', () => {
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'raw-text', data: [{ id: 'body-1', type: 'text', value: 'hello' }] } as any}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated-body' } })
expect(onChange).toHaveBeenCalled()
})
it('should initialize an empty json body and support legacy string payload rendering', () => {
const onChange = vi.fn()
const { rerender } = render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'json', data: [] } as any}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '{"a":1}' } })
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'json',
data: [expect.objectContaining({ value: '{"a":1}' })],
}))
rerender(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'json', data: 'legacy' } as any}
onChange={onChange}
/>,
)
expect(screen.getByRole('textbox')).toHaveValue('')
})
it('should switch to key-value body types and propagate key-value edits', () => {
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'none', data: [] } as any}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('radio', { name: 'form-data' }))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'form-data',
data: [expect.objectContaining({ key: '', value: '' })],
}))
onChange.mockClear()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'form-data', data: [{ id: 'body-1', type: 'text', key: 'name', value: 'alice' }] } as any}
onChange={onChange}
/>,
)
fireEvent.click(screen.getAllByDisplayValue('alice')[0]!)
fireEvent.change(screen.getAllByDisplayValue('alice')[0]!, { target: { value: 'bob' } })
expect(onChange.mock.calls.some(([payload]) => Array.isArray(payload.data) && payload.data.length === 2)).toBe(true)
expect(onChange.mock.calls.some(([payload]) => Array.isArray(payload.data) && payload.data[0]?.value === 'bob')).toBe(true)
})
it('should render the binary body picker and forward file selections', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'binary', data: [{ id: 'body-1', type: 'file', file: [] }] } as any}
onChange={onChange}
/>,
)
await user.click(screen.getByText('pick-file'))
expect(onChange).toHaveBeenCalled()
})
it('should initialize an empty binary body before saving the selected file', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'binary', data: [] } as any}
onChange={onChange}
/>,
)
expect(screen.getByText('file-filter:true:false')).toBeInTheDocument()
await user.click(screen.getByText('pick-file'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'binary',
data: [expect.objectContaining({
type: 'file',
file: ['node-1', 'file'],
})],
}))
})
it('should render the request node preview when a url exists', () => {
renderWorkflowFlowComponent(
<Node
id="node-1"
data={createData()}
/>,
{ nodes: [], edges: [] },
)
expect(screen.getByText(Method.get)).toBeInTheDocument()
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
})
it('should render nothing when the request url is empty', () => {
renderWorkflowFlowComponent(
<Node
id="node-1"
data={createData({ url: '' })}
/>,
{ nodes: [], edges: [] },
)
expect(screen.queryByText(Method.get)).not.toBeInTheDocument()
expect(screen.queryByText('https://api.example.com')).not.toBeInTheDocument()
})
it('should render the panel sections and output vars', async () => {
renderPanel()
expect(screen.getByText('body:string')).toBeInTheDocument()
expect(screen.getByText('status_code:number')).toBeInTheDocument()
expect(screen.getByText('headers:object')).toBeInTheDocument()
expect(screen.getByText('files:Array[File]')).toBeInTheDocument()
expect(screen.getAllByText('workflow.nodes.http.authorization.authorization').length).toBeGreaterThan(0)
expect(screen.getByText('workflow.nodes.http.curl.title')).toBeInTheDocument()
expect(screen.getByText('curl-panel')).toBeInTheDocument()
})
it('should hide modal overlays when the panel is readonly', () => {
mockUseConfig.mockReturnValueOnce(createConfigResult({
readOnly: true,
}))
renderPanel()
expect(screen.queryByText('curl-panel')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.http.authorization.api-key-title')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,430 @@
import type { Var } from '../../../types'
import type { IfElseNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
BlockEnum,
VarType,
} from '../../../types'
import { VarType as NumberVarType } from '../../tool/types'
import ConditionAdd from '../components/condition-add'
import ConditionFilesListValue from '../components/condition-files-list-value'
import ConditionList from '../components/condition-list'
import ConditionOperator from '../components/condition-list/condition-operator'
import ConditionNumberInput from '../components/condition-number-input'
import ConditionValue from '../components/condition-value'
import Node from '../node'
import Panel from '../panel'
import {
ComparisonOperator,
LogicalOperator,
} from '../types'
import useConfig from '../use-config'
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useNodes: () => [
{
id: 'node-1',
data: {
title: 'Start Node',
type: BlockEnum.Start,
},
},
],
}
})
vi.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-vars', () => ({
default: ({ onChange }: { onChange: (valueSelector: string[], varItem: { type: VarType }) => void }) => (
<button
type="button"
onClick={() => onChange(['node-1', 'score'], { type: VarType.number })}
>
pick-var
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInText: ({ variables }: { variables: string[] }) => <div>{variables.join('.')}</div>,
VariableLabelInNode: ({ variables }: { variables: string[] }) => <div>{variables.join('.')}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable-tag', () => ({
__esModule: true,
default: ({ valueSelector }: { valueSelector: string[] }) => <div>{valueSelector.join('.')}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/node-handle', () => ({
NodeSourceHandle: ({ handleId }: { handleId: string }) => <div data-testid={`handle-${handleId}`} />,
}))
const mockWorkflowStoreState = {
controlPromptEditorRerenderKey: 0,
pipelineId: undefined as string | undefined,
setShowInputFieldPanel: vi.fn(),
}
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: typeof mockWorkflowStoreState) => unknown) => selector(mockWorkflowStoreState),
useWorkflowStore: () => ({
getState: () => ({
...mockWorkflowStoreState,
conversationVariables: [],
dataSourceList: [],
setControlPromptEditorRerenderKey: vi.fn(),
}),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/use-match-schema-type', () => ({
__esModule: true,
default: () => ({
schemaTypeDefinitions: [],
matchSchemaType: () => undefined,
}),
}))
vi.mock('../../variable-assigner/hooks', () => ({
useGetAvailableVars: () => () => [
{
variable: ['node-1', 'score'],
type: VarType.number,
},
],
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: [] }),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<IfElseNodeType> = {}): IfElseNodeType => ({
title: 'If Else',
desc: '',
type: BlockEnum.IfElse,
isInIteration: false,
isInLoop: false,
cases: [
{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [
{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
},
],
},
],
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
filterVar: () => true,
filterNumberVar: (varPayload: Var) => varPayload.type === VarType.number,
handleAddCase: vi.fn(),
handleRemoveCase: vi.fn(),
handleSortCase: vi.fn(),
handleAddCondition: vi.fn(),
handleUpdateCondition: vi.fn(),
handleRemoveCondition: vi.fn(),
handleToggleConditionLogicalOperator: vi.fn(),
handleAddSubVariableCondition: vi.fn(),
handleRemoveSubVariableCondition: vi.fn(),
handleUpdateSubVariableCondition: vi.fn(),
handleToggleSubVariableConditionLogicalOperator: vi.fn(),
nodesOutputVars: [
{
nodeId: 'node-1',
title: 'Start Node',
vars: [
{
variable: 'answer',
type: VarType.string,
},
],
},
],
availableNodes: [],
nodesOutputNumberVars: [
{
nodeId: 'node-1',
title: 'Start Node',
vars: [
{
variable: 'score',
type: VarType.number,
},
],
},
],
availableNumberNodes: [],
varsIsVarFileAttribute: {},
...overrides,
})
const baseNodeProps = {
type: 'custom',
selected: false,
zIndex: 1,
xPos: 0,
yPos: 0,
dragging: false,
isConnectable: true,
}
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('if-else path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStoreState.controlPromptEditorRerenderKey = 0
mockWorkflowStoreState.pipelineId = undefined
mockWorkflowStoreState.setShowInputFieldPanel = vi.fn()
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Condition controls', () => {
it('should add a condition variable from the selector', async () => {
const user = userEvent.setup()
const onSelectVariable = vi.fn()
render(
<ConditionAdd
caseId="case-1"
variables={[]}
onSelectVariable={onSelectVariable}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow.nodes.ifElse.addCondition/i }))
await user.click(screen.getByText('pick-var'))
expect(onSelectVariable).toHaveBeenCalledWith('case-1', ['node-1', 'score'], { type: VarType.number })
})
it('should switch operators and number input modes', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onNumberVarTypeChange = vi.fn()
const onValueChange = vi.fn()
render(
<div>
<ConditionOperator
varType={VarType.string}
value={ComparisonOperator.contains}
onSelect={onSelect}
/>
<ConditionNumberInput
value="12"
numberVarType={NumberVarType.constant}
onNumberVarTypeChange={onNumberVarTypeChange}
onValueChange={onValueChange}
variables={[]}
unit="%"
/>
</div>,
)
await user.click(screen.getByRole('button', { name: /contains/i }))
await user.click(screen.getByText('workflow.nodes.ifElse.comparisonOperator.is'))
await user.click(screen.getByRole('button', { name: /constant/i }))
await user.click(screen.getByText('Variable'))
fireEvent.change(screen.getByDisplayValue('12'), { target: { value: '42' } })
expect(onSelect).toHaveBeenCalledWith(ComparisonOperator.is)
expect(onNumberVarTypeChange).toHaveBeenCalledWith(NumberVarType.variable)
expect(onValueChange).toHaveBeenCalledWith('42')
})
it('should toggle logical operators for a case list with multiple conditions', async () => {
const user = userEvent.setup()
const onToggleConditionLogicalOperator = vi.fn()
render(
<ConditionList
caseId="case-1"
caseItem={{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [
{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
},
{
id: 'condition-2',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.is,
value: 'world',
},
],
}}
nodeId="node-1"
nodesOutputVars={[]}
availableNodes={[]}
numberVariables={[]}
filterVar={() => true}
varsIsVarFileAttribute={{}}
onToggleConditionLogicalOperator={onToggleConditionLogicalOperator}
/>,
)
await user.click(screen.getByText('AND'))
expect(onToggleConditionLogicalOperator).toHaveBeenCalledWith('case-1')
})
})
describe('Display rendering', () => {
it('should render formatted condition values and file sub-conditions', () => {
render(
<div>
<ConditionValue
variableSelector={['node-1', 'answer']}
operator={ComparisonOperator.contains}
value="{{#node-1.answer#}}"
/>
<ConditionFilesListValue
condition={{
id: 'condition-files',
varType: VarType.object,
variable_selector: ['node-1', 'files'],
comparison_operator: ComparisonOperator.contains,
value: '',
sub_variable_condition: {
case_id: 'sub-case',
logical_operator: LogicalOperator.or,
conditions: [
{
id: 'sub-condition',
key: 'name',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: 'report',
},
],
},
}}
/>
</div>,
)
expect(screen.getByText('node-1.answer')).toBeInTheDocument()
expect(screen.getByText('{{answer}}')).toBeInTheDocument()
expect(screen.getByText('node-1.files')).toBeInTheDocument()
expect(screen.getByText('name')).toBeInTheDocument()
expect(screen.getByText('report')).toBeInTheDocument()
})
it('should render node cases, missing setup state, and else handles', () => {
render(
<Node
id="if-else-node"
{...baseNodeProps}
data={createData({
cases: [
{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [
{
id: 'condition-empty',
varType: VarType.string,
variable_selector: [],
comparison_operator: ComparisonOperator.contains,
value: '',
},
],
},
{
case_id: 'case-2',
logical_operator: LogicalOperator.or,
conditions: [
{
id: 'condition-ready',
varType: VarType.boolean,
variable_selector: ['node-1', 'passed'],
comparison_operator: ComparisonOperator.is,
value: false,
},
],
},
],
})}
/>,
)
expect(screen.getByText('IF')).toBeInTheDocument()
expect(screen.getByText('ELIF')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.ifElse.conditionNotSetup')).toBeInTheDocument()
expect(screen.getByText('False')).toBeInTheDocument()
expect(screen.getByText('ELSE')).toBeInTheDocument()
expect(screen.getByTestId('handle-case-1')).toBeInTheDocument()
expect(screen.getByTestId('handle-case-2')).toBeInTheDocument()
expect(screen.getByTestId('handle-false')).toBeInTheDocument()
})
})
describe('Panel integration', () => {
it('should add a case from the panel action and render else description', async () => {
const user = userEvent.setup()
const handleAddCase = vi.fn()
const inputs = createData({ cases: [] })
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs,
handleAddCase,
}))
render(
<Panel
id="if-else-node"
data={inputs}
panelProps={panelProps}
/>,
)
await user.click(screen.getByRole('button', { name: /elif/i }))
expect(handleAddCase).toHaveBeenCalled()
expect(screen.getByText('workflow.nodes.ifElse.elseDescription')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,266 @@
import type { ReactNode } from 'react'
import type { IterationNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast from '@/app/components/base/toast'
import { ErrorHandleMode } from '@/app/components/workflow/types'
import { BlockEnum, VarType } from '../../../types'
import AddBlock from '../add-block'
import Node from '../node'
import Panel from '../panel'
import useConfig from '../use-config'
const mockHandleNodeAdd = vi.fn()
const mockHandleNodeIterationRerender = vi.fn()
let mockNodesReadOnly = false
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
Background: ({ id }: { id: string }) => <div data-testid={id} />,
useViewport: () => ({ zoom: 1 }),
useNodesInitialized: () => true,
}
})
vi.mock('@/app/components/workflow/block-selector', () => ({
__esModule: true,
default: ({
trigger,
onSelect,
availableBlocksTypes = [],
disabled,
}: {
trigger?: (open: boolean) => ReactNode
onSelect?: (type: BlockEnum) => void
availableBlocksTypes?: BlockEnum[]
disabled?: boolean
}) => (
<div>
{trigger ? <div>{trigger(false)}</div> : null}
<button
type="button"
disabled={disabled}
onClick={() => onSelect?.(availableBlocksTypes[0] ?? BlockEnum.Code)}
>
select-block
</button>
</div>
),
}))
vi.mock('../../iteration-start', () => ({
IterationStartNodeDumb: () => <div>iteration-start-node</div>,
}))
vi.mock('../use-interactions', () => ({
useNodeIterationInteractions: () => ({
handleNodeIterationRerender: mockHandleNodeIterationRerender,
}),
}))
vi.mock('../../../hooks', () => ({
useAvailableBlocks: () => ({
availableNextBlocks: [BlockEnum.Code],
}),
useNodesInteractions: () => ({
handleNodeAdd: mockHandleNodeAdd,
}),
useNodesReadOnly: () => ({
nodesReadOnly: mockNodesReadOnly,
}),
}))
vi.mock('../../_base/components/variable/var-reference-picker', () => ({
__esModule: true,
default: ({
onChange,
availableVars,
}: {
onChange: (value: string[], kindType?: string, varInfo?: { type: VarType }) => void
availableVars?: unknown[]
}) => (
<button
type="button"
onClick={() => {
if (availableVars)
onChange(['child-node', 'text'], 'variable', { type: VarType.string })
else
onChange(['node-1', 'items'], 'variable', { type: VarType.arrayString })
}}
>
{availableVars ? 'pick-output-var' : 'pick-input-var'}
</button>
),
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const mockToastNotify = vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
const createData = (overrides: Partial<IterationNodeType> = {}): IterationNodeType => ({
title: 'Iteration',
desc: '',
type: BlockEnum.Iteration,
start_node_id: 'start-node',
iterator_selector: ['node-1', 'items'],
iterator_input_type: VarType.arrayString,
output_selector: ['child-node', 'text'],
output_type: VarType.arrayString,
is_parallel: false,
parallel_nums: 3,
error_handle_mode: ErrorHandleMode.Terminated,
flatten_output: false,
_isShowTips: false,
_children: [],
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
filterInputVar: () => true,
handleInputChange: vi.fn(),
childrenNodeVars: [],
iterationChildrenNodes: [],
handleOutputVarChange: vi.fn(),
changeParallel: vi.fn(),
changeErrorResponseMode: vi.fn(),
changeParallelNums: vi.fn(),
changeFlattenOutput: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('iteration path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodesReadOnly = false
mockUseConfig.mockReturnValue(createConfigResult())
})
it('should add the next block from the iteration start node', async () => {
const user = userEvent.setup()
render(
<AddBlock
iterationNodeId="iteration-node"
iterationNodeData={createData()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-block' }))
expect(mockHandleNodeAdd).toHaveBeenCalledWith({
nodeType: BlockEnum.Code,
pluginDefaultValue: undefined,
}, {
prevNodeId: 'start-node',
prevNodeSourceHandle: 'source',
})
})
it('should render candidate iteration nodes and show the parallel warning once', () => {
render(
<Node
id="iteration-node"
data={createData({
_isCandidate: true,
_children: [{ nodeId: 'child-1', nodeType: BlockEnum.Iteration }],
is_parallel: true,
_isShowTips: true,
})}
/>,
)
expect(screen.getByText('iteration-start-node')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'select-block' })).toBeInTheDocument()
expect(screen.getByTestId('iteration-background-iteration-node')).toBeInTheDocument()
expect(mockHandleNodeIterationRerender).toHaveBeenCalledWith('iteration-node')
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'warning',
message: 'workflow.nodes.iteration.answerNodeWarningDesc',
duration: 5000,
})
})
it('should wire panel input, output, parallel, numeric, error mode, and flatten actions', async () => {
const user = userEvent.setup()
const handleInputChange = vi.fn()
const handleOutputVarChange = vi.fn()
const changeParallel = vi.fn()
const changeParallelNums = vi.fn()
const changeErrorResponseMode = vi.fn()
const changeFlattenOutput = vi.fn()
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_parallel: true,
flatten_output: false,
}),
handleInputChange,
handleOutputVarChange,
changeParallel,
changeParallelNums,
changeErrorResponseMode,
changeFlattenOutput,
}))
render(
<Panel
id="iteration-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-input-var' }))
await user.click(screen.getByRole('button', { name: 'pick-output-var' }))
await user.click(screen.getAllByRole('switch')[0]!)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } })
await user.click(screen.getByRole('button', { name: /workflow.nodes.iteration.ErrorMethod.operationTerminated/i }))
await user.click(screen.getByText('workflow.nodes.iteration.ErrorMethod.continueOnError'))
await user.click(screen.getAllByRole('switch')[1]!)
expect(handleInputChange).toHaveBeenCalledWith(['node-1', 'items'], 'variable', { type: VarType.arrayString })
expect(handleOutputVarChange).toHaveBeenCalledWith(['child-node', 'text'], 'variable', { type: VarType.string })
expect(changeParallel).toHaveBeenCalledWith(false)
expect(changeParallelNums).toHaveBeenCalledWith(7)
expect(changeErrorResponseMode).toHaveBeenCalledWith(expect.objectContaining({
value: ErrorHandleMode.ContinueOnError,
}))
expect(changeFlattenOutput).toHaveBeenCalledWith(true)
})
it('should hide parallel controls when parallel mode is disabled', () => {
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_parallel: false,
}),
}))
render(
<Panel
id="iteration-node"
data={createData()}
panelProps={panelProps}
/>,
)
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,615 @@
import type {
ComparisonOperator,
MetadataFilteringCondition,
MetadataShape,
} from '../types'
import type { DataSet, MetadataInDoc } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
ChunkingMode,
DatasetPermission,
DataSourceType,
} from '@/models/datasets'
import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app'
import { DatasetsDetailContext } from '../../../datasets-detail-store/provider'
import { createDatasetsDetailStore } from '../../../datasets-detail-store/store'
import { BlockEnum, VarType } from '../../../types'
import AddDataset from '../components/add-dataset'
import DatasetItem from '../components/dataset-item'
import DatasetList from '../components/dataset-list'
import ConditionCommonVariableSelector from '../components/metadata/condition-list/condition-common-variable-selector'
import ConditionDate from '../components/metadata/condition-list/condition-date'
import ConditionItem from '../components/metadata/condition-list/condition-item'
import ConditionOperator from '../components/metadata/condition-list/condition-operator'
import ConditionValueMethod from '../components/metadata/condition-list/condition-value-method'
import ConditionVariableSelector from '../components/metadata/condition-list/condition-variable-selector'
import MetadataFilter from '../components/metadata/metadata-filter'
import MetadataFilterSelector from '../components/metadata/metadata-filter/metadata-filter-selector'
import MetadataTrigger from '../components/metadata/metadata-trigger'
import RetrievalConfig from '../components/retrieval-config'
import Node from '../node'
import {
LogicalOperator,
ComparisonOperator as MetadataComparisonOperator,
MetadataFilteringModeEnum,
MetadataFilteringVariableType,
} from '../types'
const mockHasEditPermissionForDataset = vi.fn((
_userId: string,
_datasetConfig: { createdBy: string, partialMemberList: string[], permission: DatasetPermission },
) => true)
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: {
icon: '📙',
icon_background: '#FFF4ED',
icon_type: 'emoji',
icon_url: '',
},
description: 'Dataset description',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 1690000000,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 1,
total_document_count: 1,
word_count: 1000,
provider: 'internal',
embedding_model: 'text-embedding-3',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
runtime_mode: 'rag_pipeline',
enable_api: false,
is_multimodal: false,
...overrides,
})
const createMetadata = (overrides: Partial<MetadataInDoc> = {}): MetadataInDoc => ({
id: 'meta-1',
name: 'topic',
type: MetadataFilteringVariableType.string,
value: 'topic',
...overrides,
})
const createCondition = (overrides: Partial<MetadataFilteringCondition> = {}): MetadataFilteringCondition => ({
id: 'condition-1',
name: 'topic',
metadata_id: 'meta-1',
comparison_operator: MetadataComparisonOperator.contains,
value: 'agent',
...overrides,
})
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { userProfile: { id: string } }) => unknown) => selector({
userProfile: { id: 'user-1' },
}),
useAppContext: () => ({
userProfile: {
timezone: 'UTC',
},
}),
}))
vi.mock('@/utils/permission', () => ({
hasEditPermissionForDataset: (
userId: string,
datasetConfig: { createdBy: string, partialMemberList: string[], permission: DatasetPermission },
) => mockHasEditPermissionForDataset(userId, datasetConfig),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
default: () => 'desktop',
MediaType: {
mobile: 'mobile',
desktop: 'desktop',
},
}))
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({
__esModule: true,
default: ({ onSelect, onClose }: { onSelect: (datasets: DataSet[]) => void, onClose: () => void }) => (
<div>
<button type="button" onClick={() => onSelect([createDataset({ id: 'dataset-2', name: 'Selected Dataset' })])}>
select-dataset
</button>
<button type="button" onClick={onClose}>
close-select-dataset
</button>
</div>
),
}))
vi.mock('@/app/components/app/configuration/dataset-config/settings-modal', () => ({
__esModule: true,
default: ({ currentDataset, onSave, onCancel }: { currentDataset: DataSet, onSave: (dataset: DataSet) => void, onCancel: () => void }) => (
<div>
<div>{currentDataset.name}</div>
<button type="button" onClick={() => onSave(createDataset({ ...currentDataset, name: 'Updated Dataset' }))}>
save-settings
</button>
<button type="button" onClick={onCancel}>
cancel-settings
</button>
</div>
),
}))
vi.mock('@/app/components/app/configuration/dataset-config/params-config/config-content', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (config: Record<string, unknown>, isRetrievalModeChange?: boolean) => void }) => (
<div>
<button
type="button"
onClick={() => onChange({
retrieval_model: RETRIEVE_TYPE.multiWay,
top_k: 8,
score_threshold_enabled: true,
score_threshold: 0.4,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-v3',
},
reranking_mode: 'weighted_score',
weights: {
weight_type: 'customized',
vector_setting: {
vector_weight: 0.7,
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding-3',
},
keyword_setting: {
keyword_weight: 0.3,
},
},
reranking_enable: true,
})}
>
apply-retrieval-config
</button>
<button
type="button"
onClick={() => onChange({
retrieval_model: RETRIEVE_TYPE.oneWay,
}, true)}
>
change-retrieval-mode
</button>
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: () => <div>model-parameter-modal</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-vars', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (valueSelector: string[], varItem: { type: VarType }) => void }) => (
<button
type="button"
onClick={() => onChange(['node-1', 'field'], { type: VarType.string })}
>
pick-var
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable-tag', () => ({
__esModule: true,
default: ({ valueSelector }: { valueSelector: string[] }) => <div>{valueSelector.join('.')}</div>,
}))
vi.mock('../components/metadata/metadata-panel', () => ({
__esModule: true,
default: ({ onCancel }: { onCancel: () => void }) => (
<div>
<div>metadata-panel</div>
<button type="button" onClick={onCancel}>
close-metadata-panel
</button>
</div>
),
}))
describe('knowledge-retrieval path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasEditPermissionForDataset.mockReturnValue(true)
})
describe('Dataset controls', () => {
it('should open dataset selector and forward selected datasets', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<AddDataset
selectedIds={['dataset-1']}
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('add-button'))
await user.click(screen.getByText('select-dataset'))
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({
id: 'dataset-2',
name: 'Selected Dataset',
}),
])
})
it('should support editing and removing a dataset item', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onRemove = vi.fn()
render(
<DatasetItem
payload={createDataset({ is_multimodal: true })}
onChange={onChange}
onRemove={onRemove}
/>,
)
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
fireEvent.mouseOver(screen.getByText('Dataset Name').closest('.group\\/dataset-item')!)
const buttons = screen.getAllByRole('button')
await user.click(buttons[0]!)
await user.click(screen.getByText('save-settings'))
await user.click(buttons[1]!)
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated Dataset' }))
expect(onRemove).toHaveBeenCalled()
})
it('should render empty and populated dataset lists', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<DatasetList
list={[]}
onChange={onChange}
/>,
)
expect(screen.getByText('appDebug.datasetConfig.knowledgeTip')).toBeInTheDocument()
rerender(
<DatasetList
list={[createDataset()]}
onChange={onChange}
/>,
)
fireEvent.mouseOver(screen.getByText('Dataset Name').closest('.group\\/dataset-item')!)
await user.click(screen.getAllByRole('button')[1]!)
expect(onChange).toHaveBeenCalledWith([])
})
})
describe('Retrieval settings', () => {
it('should open retrieval config and map config updates back to workflow payload', async () => {
const user = userEvent.setup()
const onRetrievalModeChange = vi.fn()
const onMultipleRetrievalConfigChange = vi.fn()
render(
<RetrievalConfig
payload={{
retrieval_mode: RETRIEVE_TYPE.multiWay,
multiple_retrieval_config: {
top_k: 3,
score_threshold: null,
},
}}
onRetrievalModeChange={onRetrievalModeChange}
onMultipleRetrievalConfigChange={onMultipleRetrievalConfigChange}
rerankModalOpen
onRerankModelOpenChange={vi.fn()}
selectedDatasets={[createDataset()]}
/>,
)
await user.click(screen.getByText('apply-retrieval-config'))
await user.click(screen.getByText('change-retrieval-mode'))
expect(onMultipleRetrievalConfigChange).toHaveBeenCalledWith(expect.objectContaining({
top_k: 8,
score_threshold: 0.4,
reranking_model: {
provider: 'cohere',
model: 'rerank-v3',
},
reranking_enable: true,
}))
expect(onRetrievalModeChange).toHaveBeenCalledWith(RETRIEVE_TYPE.oneWay)
})
})
describe('Metadata controls', () => {
it('should select metadata filter mode from the dropdown', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<MetadataFilterSelector
value={MetadataFilteringModeEnum.disabled}
onSelect={onSelect}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.options.disabled.title/i }))
await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.options.manual.title'))
expect(onSelect).toHaveBeenCalledWith(MetadataFilteringModeEnum.manual)
})
it('should remove stale metadata conditions and open the manual metadata panel', async () => {
const user = userEvent.setup()
const handleRemoveCondition = vi.fn()
render(
<MetadataTrigger
selectedDatasetsLoaded
metadataList={[createMetadata()]}
metadataFilteringConditions={{
logical_operator: LogicalOperator.and,
conditions: [
createCondition(),
createCondition({
id: 'condition-stale',
metadata_id: 'missing',
name: 'missing',
}),
],
}}
handleAddCondition={vi.fn()}
handleRemoveCondition={handleRemoveCondition}
handleToggleConditionLogicalOperator={vi.fn()}
handleUpdateCondition={vi.fn()}
/>,
)
expect(handleRemoveCondition).toHaveBeenCalledWith('condition-stale')
await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.panel.conditions/i }))
expect(screen.getByText('metadata-panel')).toBeInTheDocument()
})
it('should render automatic and manual metadata filter states', async () => {
const user = userEvent.setup()
const baseProps: MetadataShape = {
metadataList: [createMetadata()],
metadataFilteringConditions: {
logical_operator: LogicalOperator.and,
conditions: [createCondition()],
},
selectedDatasetsLoaded: true,
handleAddCondition: vi.fn(),
handleRemoveCondition: vi.fn(),
handleToggleConditionLogicalOperator: vi.fn(),
handleUpdateCondition: vi.fn(),
}
const { rerender } = render(
<MetadataFilter
{...baseProps}
metadataFilterMode={MetadataFilteringModeEnum.automatic}
handleMetadataFilterModeChange={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.options.automatic.title/i })).toBeInTheDocument()
rerender(
<MetadataFilter
{...baseProps}
metadataFilterMode={MetadataFilteringModeEnum.manual}
handleMetadataFilterModeChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.panel.conditions/i }))
expect(screen.getByText('metadata-panel')).toBeInTheDocument()
})
})
describe('Condition inputs', () => {
it('should toggle value method and keep the same option idempotent', async () => {
const user = userEvent.setup()
const onValueMethodChange = vi.fn()
render(
<ConditionValueMethod
valueMethod="variable"
onValueMethodChange={onValueMethodChange}
/>,
)
await user.click(screen.getByRole('button', { name: /variable/i }))
await user.click(screen.getByText('Constant'))
await user.click(screen.getByRole('button', { name: /variable/i }))
await user.click(screen.getAllByText('Variable')[1]!)
expect(onValueMethodChange).toHaveBeenCalledTimes(1)
expect(onValueMethodChange).toHaveBeenCalledWith('constant')
})
it('should select workflow and common variables', async () => {
const user = userEvent.setup()
const onVariableChange = vi.fn()
const onCommonVariableChange = vi.fn()
const { rerender } = render(
<ConditionVariableSelector
onChange={onVariableChange}
varType={VarType.string}
/>,
)
await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.panel.select'))
await user.click(screen.getByText('pick-var'))
expect(onVariableChange).toHaveBeenCalledWith(['node-1', 'field'], { type: VarType.string })
rerender(
<ConditionCommonVariableSelector
variables={[{ name: 'common', type: 'string', value: 'sys.user_name' }]}
varType={VarType.string}
onChange={onCommonVariableChange}
/>,
)
await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.panel.select'))
await user.click(screen.getByText('sys.user_name'))
expect(onCommonVariableChange).toHaveBeenCalledWith('sys.user_name')
})
it('should update operator, clear date values, and remove conditions', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onDateChange = vi.fn()
const onRemoveCondition = vi.fn()
const onUpdateCondition = vi.fn()
const { container } = render(
<div>
<ConditionOperator
variableType={MetadataFilteringVariableType.string}
value={MetadataComparisonOperator.contains}
onSelect={onSelect}
/>
<ConditionDate
value={1710000000}
onChange={onDateChange}
/>
<ConditionItem
metadataList={[createMetadata()]}
condition={createCondition()}
onRemoveCondition={onRemoveCondition}
onUpdateCondition={onUpdateCondition}
/>
</div>,
)
await user.click(screen.getAllByRole('button', { name: /contains/i })[0]!)
await user.click(screen.getByText('workflow.nodes.ifElse.comparisonOperator.is'))
await user.click(screen.getByText(/March 09 2024/).nextElementSibling as Element)
fireEvent.change(screen.getByDisplayValue('agent'), { target: { value: 'updated-agent' } })
fireEvent.click(container.querySelector('.ml-1.mt-1') as Element)
expect(onSelect).toHaveBeenCalledWith(MetadataComparisonOperator.is as ComparisonOperator)
expect(onDateChange).toHaveBeenCalledWith()
expect(onUpdateCondition).toHaveBeenCalledWith('condition-1', expect.objectContaining({ value: 'updated-agent' }))
expect(onRemoveCondition).toHaveBeenCalledWith('condition-1')
})
})
describe('Node rendering', () => {
it('should render selected datasets from the detail store and hide when none are selected', () => {
const store = createDatasetsDetailStore()
store.getState().updateDatasetsDetail([createDataset()])
const renderNode = (datasetIds: string[]) => render(
<DatasetsDetailContext.Provider value={store}>
<Node
id="knowledge-node"
data={{
type: BlockEnum.KnowledgeRetrieval,
title: 'Knowledge Retrieval',
desc: '',
dataset_ids: datasetIds,
query_variable_selector: [],
query_attachment_selector: [],
retrieval_mode: RETRIEVE_TYPE.multiWay,
}}
/>
</DatasetsDetailContext.Provider>,
)
const { rerender, container } = renderNode(['dataset-1'])
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
rerender(
<DatasetsDetailContext.Provider value={store}>
<Node
id="knowledge-node"
data={{
type: BlockEnum.KnowledgeRetrieval,
title: 'Knowledge Retrieval',
desc: '',
dataset_ids: [],
query_variable_selector: [],
query_attachment_selector: [],
retrieval_mode: RETRIEVE_TYPE.multiWay,
}}
/>
</DatasetsDetailContext.Provider>,
)
expect(container).toBeEmptyDOMElement()
})
})
})

View File

@ -0,0 +1,309 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { ListFilterNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { act, fireEvent, render, screen, waitFor } 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 ExtractInput from '../components/extract-input'
import LimitConfig from '../components/limit-config'
import SubVariablePicker from '../components/sub-variable-picker'
import Node from '../node'
import Panel from '../panel'
import { OrderBy } from '../types'
import useConfig from '../use-config'
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
default: vi.fn((_nodeId: string, options?: any) => ({
availableVars: [
{ variable: ['node-1', 'size'], type: VarType.number },
{ variable: ['node-1', 'name'], type: VarType.string },
].filter(varPayload => options?.filterVar ? options.filterVar(varPayload) : true),
availableNodesWithParent: [{ id: 'node-1', data: { title: 'Answer', type: BlockEnum.Answer } }],
})),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({
default: ({ value, onChange, placeholder, className, readOnly, onFocusChange }: any) => (
<input
value={value}
placeholder={placeholder}
className={className}
readOnly={readOnly}
onFocus={() => onFocusChange?.(true)}
onBlur={() => onFocusChange?.(false)}
onChange={event => onChange(event.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-number-with-slider', () => ({
default: ({ value, onChange }: any) => (
<button type="button" onClick={() => onChange(value + 1)}>
slider-{value}
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({
default: ({ title, onSelect }: any) => <button type="button" onClick={onSelect}>{title}</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type }: any) => <div>{name}:{type}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange }: any) => <button type="button" onClick={() => onChange(['node-1', 'items'])}>pick-var</button>,
}))
vi.mock('../components/filter-condition', () => ({
default: ({ onChange }: any) => <button type="button" onClick={() => onChange({ key: 'size' })}>filter-condition</button>,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<ListFilterNodeType> = {}): ListFilterNodeType => ({
title: 'List Operator',
desc: '',
type: BlockEnum.ListFilter,
variable: ['node-1', 'items'],
var_type: VarType.arrayNumber,
item_var_type: VarType.number,
filter_by: { enabled: true, conditions: [{ key: 'size', comparison_operator: 'equal', value: '1' }] as any },
extract_by: { enabled: true, serial: '1' },
limit: { enabled: true, size: 10 },
order_by: { enabled: true, key: 'size', value: OrderBy.ASC },
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
filterVar: vi.fn(() => true),
varType: VarType.arrayNumber,
itemVarType: VarType.number,
itemVarTypeShowName: 'number',
hasSubVariable: true,
handleVarChanges: vi.fn(),
handleFilterEnabledChange: vi.fn(),
handleFilterChange: vi.fn(),
handleLimitChange: vi.fn(),
handleOrderByEnabledChange: vi.fn(),
handleOrderByKeyChange: vi.fn(),
handleOrderByTypeChange: vi.fn(() => vi.fn()),
handleExtractsEnabledChange: vi.fn(),
handleExtractsChange: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
const renderPanel = (data: ListFilterNodeType = createData()) => (
render(<Panel id="node-1" data={data} panelProps={panelProps} />)
)
describe('list-operator path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseConfig.mockReturnValue(createConfigResult())
})
// The list-operator path should expose extract, limit, ordering, and node variable previews.
describe('Path Integration', () => {
it('should update the extract input', async () => {
const onChange = vi.fn()
const { rerender } = render(
<ExtractInput
nodeId="node-1"
readOnly={false}
value="1"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } })
fireEvent.focus(screen.getByDisplayValue('1'))
expect(screen.getByDisplayValue('1')).toHaveClass('border-components-input-border-active')
rerender(
<ExtractInput
nodeId="node-1"
readOnly
value=""
onChange={onChange}
/>,
)
expect(onChange).toHaveBeenCalled()
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', '')
})
it('should change the selected sub variable', async () => {
const onChange = vi.fn()
const { unmount } = render(
<SubVariablePicker
value="size"
onChange={onChange}
/>,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
})
const option = await screen.findByText('name')
await act(async () => {
fireEvent.click(option)
})
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('name')
})
unmount()
render(
<SubVariablePicker
value=""
onChange={onChange}
/>,
)
expect(screen.getByText('common.placeholder.select')).toBeInTheDocument()
})
it('should toggle limit and update the size slider', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<LimitConfig
readonly={false}
config={{ enabled: true, size: 10 }}
onChange={onChange}
/>,
)
await user.click(screen.getByText('slider-10'))
expect(onChange).toHaveBeenCalledWith({ enabled: true, size: 11 })
rerender(
<LimitConfig
readonly={false}
config={{ enabled: false, size: 10 }}
onChange={onChange}
/>,
)
expect(screen.queryByText('slider-10')).not.toBeInTheDocument()
await user.click(screen.getByRole('switch'))
expect(onChange).toHaveBeenCalledWith({ enabled: true, size: 10 })
})
it('should render the selected input variable in the node preview', () => {
renderWorkflowFlowComponent(
<Node
id="node-2"
data={createData()}
/>,
{
nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Answer, title: 'Answer' } as any }],
edges: [],
},
)
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(screen.getByText('items')).toBeInTheDocument()
})
it('should resolve system variables through the start node and return null without a variable', () => {
const { rerender } = renderWorkflowFlowComponent(
<Node
id="node-2"
data={createData({ variable: ['sys', 'files'] as any })}
/>,
{
nodes: [{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start, title: 'Start' } as any }],
edges: [],
},
)
expect(screen.getByText('Start')).toBeInTheDocument()
rerender(
<Node
id="node-2"
data={createData({ variable: [] as any })}
/>,
)
expect(screen.queryByText('workflow.nodes.listFilter.inputVar')).not.toBeInTheDocument()
expect(screen.queryByText('Start')).not.toBeInTheDocument()
})
it('should render the panel controls and output vars', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByText('pick-var'))
await user.click(screen.getByText('filter-condition'))
await user.click(screen.getByText('workflow.nodes.listFilter.asc'))
expect(screen.getByText('result:Array[number]')).toBeInTheDocument()
expect(screen.getByText('first_record:number')).toBeInTheDocument()
expect(screen.getByText('last_record:number')).toBeInTheDocument()
})
it('should hide disabled sections and render order controls without sub variables', () => {
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
variable: undefined as any,
filter_by: { enabled: false, conditions: [] as any },
extract_by: { enabled: false, serial: '' },
order_by: { enabled: false, key: '', value: OrderBy.ASC },
}),
hasSubVariable: false,
}))
const { rerender } = renderPanel()
expect(screen.queryByText('filter-condition')).not.toBeInTheDocument()
expect(screen.queryByDisplayValue('1')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.listFilter.asc')).not.toBeInTheDocument()
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
order_by: { enabled: true, key: '', value: OrderBy.ASC },
}),
hasSubVariable: false,
}))
rerender(<Panel id="node-1" data={createData()} panelProps={panelProps} />)
expect(screen.getByText('workflow.nodes.listFilter.asc')).toBeInTheDocument()
expect(screen.queryByText('common.placeholder.select')).not.toBeInTheDocument()
})
})
})

View File

@ -1,10 +1,8 @@
import type { LLMNodeType } from '../types'
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ProviderContextState } from '@/context/provider-context'
import type { PanelProps } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { defaultPlan } from '@/app/components/billing/config'
import { screen } from '@testing-library/react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
@ -12,17 +10,14 @@ import {
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useProviderContextSelector } from '@/context/provider-context'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { ProviderContext } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
import { BlockEnum } from '../../../types'
import Panel from '../panel'
const mockUseConfig = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: vi.fn(),
}))
vi.mock('../use-config', () => ({
default: (...args: unknown[]) => mockUseConfig(...args),
}))
@ -31,80 +26,12 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-param
default: () => <div data-testid="model-parameter-modal" />,
}))
vi.mock('../components/config-prompt', () => ({
default: () => <div data-testid="config-prompt" />,
}))
vi.mock('../../_base/components/config-vision', () => ({
default: () => null,
}))
vi.mock('../../_base/components/memory-config', () => ({
default: () => null,
}))
vi.mock('../../_base/components/variable/var-reference-picker', () => ({
default: () => null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
default: () => null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () => ({
default: () => null,
}))
vi.mock('../components/reasoning-format-config', () => ({
default: () => null,
}))
vi.mock('../components/structure-output', () => ({
default: () => null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
VarItem: () => null,
}))
type MockUseConfigReturn = ReturnType<typeof mockUseConfig>
const modelProviderSelector = vi.mocked(useProviderContextSelector)
const createProviderContextState = (modelProviders: ModelProvider[]): ProviderContextState => ({
modelProviders,
refreshModelProviders: vi.fn(),
textGenerationModelList: [],
supportRetrievalMethods: [],
isAPIKeySet: true,
plan: defaultPlan,
isFetchedPlan: true,
enableBilling: false,
onPlanInfoChanged: vi.fn(),
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
datasetOperatorEnabled: false,
enableEducationPlan: false,
isEducationWorkspace: false,
isEducationAccount: false,
allowRefreshEducationVerify: false,
educationAccountExpireAt: null,
isLoadingEducationAccountInfo: false,
isFetchingEducationAccountInfo: false,
webappCopyrightEnabled: false,
licenseLimit: {
workspace_members: {
size: 0,
limit: 0,
},
},
refreshLicenseLimit: vi.fn(),
isAllowTransferWorkspace: false,
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
humanInputEmailDeliveryEnabled: false,
})
const createMockModelProvider = (provider: string): ModelProvider => ({
provider,
label: { en_US: provider, zh_Hans: provider },
@ -195,21 +122,27 @@ const buildUseConfigResult = (overrides?: Partial<MockUseConfigReturn>) => ({
})
const renderPanel = (data?: Partial<LLMNodeType>) => {
return render(
<Panel
id="llm-node"
data={{ ...baseNodeData, ...data }}
panelProps={panelProps}
/>,
return renderWorkflowFlowComponent(
<ProviderContext.Provider value={createMockProviderContextValue({
modelProviders: [createMockModelProvider('openai')],
isFetchedPlan: true,
})}
>
<Panel
id="llm-node"
data={{ ...baseNodeData, ...data }}
panelProps={panelProps}
/>
</ProviderContext.Provider>,
{
hooksStoreProps: {},
},
)
}
describe('LLM Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
modelProviderSelector.mockImplementation(selector => selector(
createProviderContextState([createMockModelProvider('openai')]),
))
mockUseConfig.mockReturnValue(buildUseConfigResult())
})

View File

@ -0,0 +1,665 @@
import type { NodeOutPutVar } from '../../../types'
import type { Condition, LoopNodeType, LoopVariable } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ErrorHandleMode, ValueType } from '@/app/components/workflow/types'
import {
BlockEnum,
VarType,
} from '../../../types'
import { VarType as NumberVarType } from '../../tool/types'
import AddBlock from '../add-block'
import ConditionAdd from '../components/condition-add'
import ConditionFilesListValue from '../components/condition-files-list-value'
import ConditionList from '../components/condition-list'
import ConditionItem from '../components/condition-list/condition-item'
import ConditionOperator from '../components/condition-list/condition-operator'
import ConditionNumberInput from '../components/condition-number-input'
import ConditionValue from '../components/condition-value'
import LoopVariables from '../components/loop-variables'
import FormItem from '../components/loop-variables/form-item'
import InputModeSelect from '../components/loop-variables/input-mode-selec'
import VariableTypeSelect from '../components/loop-variables/variable-type-select'
import InsertBlock from '../insert-block'
import Node from '../node'
import Panel from '../panel'
import {
ComparisonOperator,
LogicalOperator,
} from '../types'
import useConfig from '../use-config'
const mockHandleNodeAdd = vi.fn()
const mockHandleNodeLoopRerender = vi.fn()
const mockToastNotify = vi.fn()
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
Background: ({ id }: { id: string }) => <div data-testid={id} />,
useViewport: () => ({ zoom: 1 }),
useNodesInitialized: () => true,
useStore: (selector: (state: { d3Selection: null, d3Zoom: null }) => unknown) => selector({
d3Selection: null,
d3Zoom: null,
}),
}
})
vi.mock('@/app/components/workflow/block-selector', () => ({
__esModule: true,
default: ({
onSelect,
onOpenChange,
open,
availableBlocksTypes = [],
trigger,
disabled,
}: {
onSelect?: (type: BlockEnum) => void
onOpenChange?: (open: boolean) => void
open?: boolean
availableBlocksTypes?: BlockEnum[]
trigger?: (open: boolean) => React.ReactNode
disabled?: boolean
}) => (
<div>
{trigger ? <div>{trigger(Boolean(open))}</div> : null}
<button
type="button"
disabled={disabled}
onClick={() => {
onOpenChange?.(!open)
onSelect?.(availableBlocksTypes[0] ?? BlockEnum.LLM)
}}
>
select-block
</button>
</div>
),
}))
vi.mock('../../loop-start', () => ({
LoopStartNodeDumb: () => <div>loop-start-node</div>,
}))
vi.mock('../use-interactions', () => ({
useNodeLoopInteractions: () => ({
handleNodeLoopRerender: mockHandleNodeLoopRerender,
}),
}))
vi.mock('../../../hooks', () => ({
useAvailableBlocks: () => ({
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.LLM],
}),
useNodesInteractions: () => ({
handleNodeAdd: mockHandleNodeAdd,
}),
useNodesReadOnly: () => ({
nodesReadOnly: false,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-vars', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (valueSelector: string[], varItem: { type: VarType }) => void }) => (
<button
type="button"
onClick={() => onChange(['node-1', 'score'], { type: VarType.number })}
>
pick-var
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (value: string) => void }) => (
<button
type="button"
onClick={() => onChange('{{#node-1.score#}}')}
>
pick-reference
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInNode: ({ variables }: { variables: string[] }) => <div>{variables.join('.')}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable-tag', () => ({
__esModule: true,
default: ({ valueSelector }: { valueSelector: string[] }) => <div>{valueSelector.join('.')}</div>,
}))
const mockWorkflowStoreState = {
controlPromptEditorRerenderKey: 0,
pipelineId: undefined as string | undefined,
setShowInputFieldPanel: vi.fn(),
}
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: typeof mockWorkflowStoreState) => unknown) => selector(mockWorkflowStoreState),
useWorkflowStore: () => ({
getState: () => ({
...mockWorkflowStoreState,
conversationVariables: [],
dataSourceList: [],
setControlPromptEditorRerenderKey: vi.fn(),
}),
}),
}))
vi.mock('../../variable-assigner/hooks', () => ({
useGetAvailableVars: () => () => [
{
nodeId: 'node-1',
title: 'Start Node',
vars: [
{
variable: 'score',
type: VarType.number,
},
],
},
],
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
__esModule: true,
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
<textarea
aria-label="code-editor"
value={value}
onChange={e => onChange(e.target.value)}
/>
),
}))
vi.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: (payload: unknown) => mockToastNotify(payload),
},
}))
vi.mock('../../_base/components/input-number-with-slider', () => ({
__esModule: true,
default: ({ value, onChange }: { value: number, onChange: (value: number) => void }) => (
<input
aria-label="loop-count"
type="number"
value={value}
onChange={e => onChange(Number(e.target.value))}
/>
),
}))
vi.mock('../../_base/components/split', () => ({
__esModule: true,
default: ({ className }: { className?: string }) => <div data-testid="split" className={className} />,
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createCondition = (overrides: Partial<Condition> = {}): Condition => ({
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
...overrides,
})
const createLoopVariable = (overrides: Partial<LoopVariable> = {}): LoopVariable => ({
id: 'loop-var-1',
label: 'item',
var_type: VarType.string,
value_type: ValueType.constant,
value: 'value',
...overrides,
})
const createNodeOutputVar = (vars: NodeOutPutVar['vars']): NodeOutPutVar => ({
nodeId: 'node-1',
title: 'Start Node',
vars,
})
const createData = (overrides: Partial<LoopNodeType> = {}): LoopNodeType => ({
title: 'Loop',
desc: '',
type: BlockEnum.Loop,
start_node_id: 'start-node',
loop_id: 'loop-node',
logical_operator: LogicalOperator.and,
break_conditions: [createCondition()],
loop_count: 3,
error_handle_mode: ErrorHandleMode.ContinueOnError,
loop_variables: [createLoopVariable()],
_children: [],
isInIteration: false,
isInLoop: false,
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
filterInputVar: vi.fn(() => true),
childrenNodeVars: [createNodeOutputVar([{ variable: 'answer', type: VarType.string }])],
loopChildrenNodes: [
{
id: 'node-1',
data: {
title: 'Start Node',
type: BlockEnum.Start,
},
} as ReturnType<typeof useConfig>['loopChildrenNodes'][number],
],
handleAddCondition: vi.fn(),
handleRemoveCondition: vi.fn(),
handleUpdateCondition: vi.fn(),
handleToggleConditionLogicalOperator: vi.fn(),
handleAddSubVariableCondition: vi.fn(),
handleUpdateSubVariableCondition: vi.fn(),
handleRemoveSubVariableCondition: vi.fn(),
handleToggleSubVariableConditionLogicalOperator: vi.fn(),
handleUpdateLoopCount: vi.fn(),
changeErrorResponseMode: vi.fn(),
handleAddLoopVariable: vi.fn(),
handleRemoveLoopVariable: vi.fn(),
handleUpdateLoopVariable: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('loop path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHandleNodeAdd.mockReset()
mockHandleNodeLoopRerender.mockReset()
mockWorkflowStoreState.controlPromptEditorRerenderKey = 0
mockWorkflowStoreState.pipelineId = undefined
mockWorkflowStoreState.setShowInputFieldPanel = vi.fn()
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Condition controls', () => {
it('should add a condition variable from the selector', async () => {
const user = userEvent.setup()
const onSelectVariable = vi.fn()
render(
<ConditionAdd
variables={[createNodeOutputVar([{ variable: 'score', type: VarType.number }])]}
onSelectVariable={onSelectVariable}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow.nodes.ifElse.addCondition/i }))
await user.click(screen.getByText('pick-var'))
expect(onSelectVariable).toHaveBeenCalledWith(['node-1', 'score'], { type: VarType.number })
})
it('should switch operators and number input modes', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onNumberVarTypeChange = vi.fn()
const onValueChange = vi.fn()
render(
<div>
<ConditionOperator
varType={VarType.string}
value={ComparisonOperator.contains}
onSelect={onSelect}
/>
<ConditionNumberInput
value="12"
numberVarType={NumberVarType.constant}
onNumberVarTypeChange={onNumberVarTypeChange}
onValueChange={onValueChange}
variables={[createNodeOutputVar([{ variable: 'score', type: VarType.number }])]}
unit="%"
/>
</div>,
)
await user.click(screen.getByRole('button', { name: /contains/i }))
await user.click(screen.getByText('workflow.nodes.ifElse.comparisonOperator.is'))
await user.click(screen.getByRole('button', { name: /constant/i }))
await user.click(screen.getByText('Variable'))
fireEvent.change(screen.getByDisplayValue('12'), { target: { value: '42' } })
expect(onSelect).toHaveBeenCalledWith(ComparisonOperator.is)
expect(onNumberVarTypeChange).toHaveBeenCalledWith(NumberVarType.variable)
expect(onValueChange).toHaveBeenCalledWith('42')
})
it('should toggle logical operators for a condition list with boolean conditions', async () => {
const user = userEvent.setup()
const onToggleConditionLogicalOperator = vi.fn()
render(
<ConditionList
conditions={[
createCondition({
id: 'condition-1',
varType: VarType.boolean,
comparison_operator: ComparisonOperator.is,
value: true,
}),
createCondition({
id: 'condition-2',
varType: VarType.boolean,
comparison_operator: ComparisonOperator.is,
value: false,
}),
]}
logicalOperator={LogicalOperator.and}
nodeId="loop-node"
availableNodes={[]}
numberVariables={[]}
availableVars={[]}
onToggleConditionLogicalOperator={onToggleConditionLogicalOperator}
/>,
)
await user.click(screen.getByText('AND'))
expect(onToggleConditionLogicalOperator).toHaveBeenCalled()
})
it('should render condition values, file sub-conditions, and select updates', async () => {
const onUpdateCondition = vi.fn()
const onRemoveCondition = vi.fn()
const onAddSubVariableCondition = vi.fn()
render(
<div>
<ConditionValue
variableSelector={['node-1', 'answer']}
operator={ComparisonOperator.contains}
value="{{#node-1.answer#}}"
/>
<ConditionFilesListValue
condition={{
id: 'condition-files',
varType: VarType.object,
variable_selector: ['node-1', 'files'],
comparison_operator: ComparisonOperator.contains,
value: '',
sub_variable_condition: {
logical_operator: LogicalOperator.or,
conditions: [
{
id: 'sub-condition',
key: 'name',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: 'report',
},
],
},
}}
/>
<ConditionItem
conditionId="condition-select"
condition={{
id: 'condition-select',
key: 'type',
varType: VarType.string,
comparison_operator: ComparisonOperator.in,
value: ['pdf'],
}}
isSubVariableKey
nodeId="loop-node"
availableNodes={[]}
numberVariables={[]}
availableVars={[]}
onUpdateSubVariableCondition={vi.fn()}
onRemoveSubVariableCondition={vi.fn()}
onAddSubVariableCondition={onAddSubVariableCondition}
/>
<ConditionItem
conditionId="condition-string"
condition={createCondition({ id: 'condition-string', value: 'draft' })}
nodeId="loop-node"
availableNodes={[]}
numberVariables={[]}
availableVars={[]}
onUpdateCondition={onUpdateCondition}
onRemoveCondition={onRemoveCondition}
/>
</div>,
)
expect(screen.getAllByText('node-1.answer')).toHaveLength(2)
expect(screen.getByText('{{answer}}')).toBeInTheDocument()
expect(screen.getByText('node-1.files')).toBeInTheDocument()
expect(screen.getByText('name')).toBeInTheDocument()
expect(screen.getByText('report')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(onUpdateCondition).not.toHaveBeenCalled()
expect(onRemoveCondition).not.toHaveBeenCalled()
})
})
describe('Loop variables', () => {
it('should render empty state and update loop variable items', async () => {
const user = userEvent.setup()
const handleRemoveLoopVariable = vi.fn()
const handleUpdateLoopVariable = vi.fn()
const { rerender } = render(
<LoopVariables
variables={[]}
nodeId="loop-node"
handleRemoveLoopVariable={handleRemoveLoopVariable}
handleUpdateLoopVariable={handleUpdateLoopVariable}
/>,
)
expect(screen.getByText('workflow.nodes.loop.setLoopVariables')).toBeInTheDocument()
rerender(
<LoopVariables
variables={[createLoopVariable({
value_type: ValueType.variable,
value: '',
})]}
nodeId="loop-node"
handleRemoveLoopVariable={handleRemoveLoopVariable}
handleUpdateLoopVariable={handleUpdateLoopVariable}
/>,
)
fireEvent.change(screen.getByDisplayValue('item'), { target: { value: 'loop_item' } })
await user.click(screen.getByText('pick-reference'))
await user.click(screen.getAllByRole('button').at(-1)!)
expect(handleUpdateLoopVariable).toHaveBeenCalledWith('loop-var-1', { label: 'loop_item' })
expect(handleUpdateLoopVariable).toHaveBeenCalledWith('loop-var-1', { value: '{{#node-1.score#}}' })
expect(handleRemoveLoopVariable).toHaveBeenCalledWith('loop-var-1')
})
it('should render variable mode, variable type, and form values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<div>
<InputModeSelect
value={ValueType.constant}
onChange={vi.fn()}
/>
<VariableTypeSelect
value={VarType.string}
onChange={vi.fn()}
/>
<FormItem
nodeId="loop-node"
item={createLoopVariable({
value_type: ValueType.constant,
var_type: VarType.arrayBoolean,
value: [false],
})}
onChange={onChange}
/>
</div>,
)
expect(screen.getByText('Constant')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
await user.click(screen.getByText('True'))
await user.click(screen.getByRole('button', { name: /workflow.chatVariable.modal.addArrayValue/i }))
expect(onChange).toHaveBeenCalledWith([true])
expect(onChange).toHaveBeenCalledWith([false, false])
})
it('should edit string and object loop variable values', () => {
const onStringChange = vi.fn()
const onObjectChange = vi.fn()
render(
<div>
<FormItem
nodeId="loop-node"
item={createLoopVariable({
id: 'loop-var-string',
var_type: VarType.string,
value_type: ValueType.constant,
value: 'draft',
})}
onChange={onStringChange}
/>
<FormItem
nodeId="loop-node"
item={createLoopVariable({
id: 'loop-var-object',
var_type: VarType.arrayObject,
value_type: ValueType.constant,
value: '[{\"id\":1}]',
})}
onChange={onObjectChange}
/>
</div>,
)
fireEvent.change(screen.getByDisplayValue('draft'), { target: { value: 'published' } })
fireEvent.change(screen.getByLabelText('code-editor'), { target: { value: '[{\"id\":2}]' } })
expect(onStringChange).toHaveBeenCalledWith('published')
expect(onObjectChange).toHaveBeenCalledWith('[{"id":2}]')
})
})
describe('Node actions', () => {
it('should add and insert loop blocks', async () => {
const user = userEvent.setup()
render(
<div>
<AddBlock
loopNodeId="loop-node"
loopNodeData={createData({ start_node_id: 'start-node' })}
/>
<InsertBlock
startNodeId="start-node"
availableBlocksTypes={[BlockEnum.Code]}
/>
</div>,
)
await user.click(screen.getAllByText('select-block')[0]!)
await user.click(screen.getAllByText('select-block')[1]!)
expect(mockHandleNodeAdd).toHaveBeenCalledTimes(2)
expect(mockHandleNodeAdd).toHaveBeenCalledWith(expect.objectContaining({
nodeType: expect.any(String),
}), expect.objectContaining({
prevNodeId: 'start-node',
prevNodeSourceHandle: 'source',
}))
expect(mockHandleNodeAdd).toHaveBeenCalledWith(expect.objectContaining({
nodeType: expect.any(String),
}), expect.objectContaining({
nextNodeId: 'start-node',
nextNodeTargetHandle: 'target',
}))
})
it('should render loop node candidate state and rerender children', () => {
render(
<Node
id="loop-node"
data={createData({
_isCandidate: true,
_children: [{ nodeId: 'child-1', nodeType: BlockEnum.LoopStart }],
})}
/>,
)
expect(screen.getByText('loop-start-node')).toBeInTheDocument()
expect(screen.getByTestId('loop-background-loop-node')).toBeInTheDocument()
expect(screen.getByText('select-block')).toBeInTheDocument()
expect(mockHandleNodeLoopRerender).toHaveBeenCalledWith('loop-node')
})
})
describe('Panel integration', () => {
it('should add loop variables and update loop count from the panel', async () => {
const handleAddLoopVariable = vi.fn()
const handleUpdateLoopCount = vi.fn()
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
break_conditions: [],
loop_variables: [],
}),
handleAddLoopVariable,
handleUpdateLoopCount,
}))
const { container } = render(
<Panel
id="loop-node"
data={createData({
break_conditions: [],
loop_variables: [],
})}
panelProps={panelProps}
/>,
)
fireEvent.click(container.querySelector('.mr-4.flex.h-5.w-5.cursor-pointer.items-center.justify-center') as HTMLElement)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '8' } })
expect(handleAddLoopVariable).toHaveBeenCalled()
expect(handleUpdateLoopCount).toHaveBeenCalledWith(8)
expect(screen.getByText('workflow.nodes.loop.setLoopVariables')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,851 @@
import type { ReactNode } from 'react'
import type { Var } from '../../../types'
import type { Param, ParameterExtractorNodeType } from '../types'
import type { ToolParameter } from '@/app/components/tools/types'
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast from '@/app/components/base/toast'
import {
useTextGenerationCurrentProviderAndModelAndModelList,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { CollectionType } from '@/app/components/tools/types'
import { AppModeEnum } from '@/types/app'
import { BlockEnum } from '../../../types'
import ImportFromTool from '../components/extract-parameter/import-from-tool'
import ExtractParameter from '../components/extract-parameter/list'
import AddExtractParameter from '../components/extract-parameter/update'
import ReasoningModePicker from '../components/reasoning-mode-picker'
import Node from '../node'
import Panel from '../panel'
import { ParamType, ReasoningModeType } from '../types'
import useConfig from '../use-config'
type MockToolCollection = {
id: string
tools: Array<{
name: string
parameters: ToolParameter[]
}>
}
let mockBuiltInTools: MockToolCollection[] = []
let mockCustomTools: MockToolCollection[] = []
let mockWorkflowTools: MockToolCollection[] = []
let mockSelectedToolInfo: ToolDefaultValue | undefined
let mockBlockSelectorOpen = false
vi.mock('@/app/components/workflow/block-selector', () => ({
__esModule: true,
default: ({
trigger,
onSelect,
}: {
trigger?: (open: boolean) => ReactNode
onSelect?: (type: BlockEnum, value?: ToolDefaultValue) => void
}) => (
<button
type="button"
onClick={() => onSelect?.(BlockEnum.Tool, mockSelectedToolInfo)}
>
{trigger ? trigger(mockBlockSelectorOpen) : 'select-tool'}
</button>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
useTextGenerationCurrentProviderAndModelAndModelList: vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: mockBuiltInTools }),
useAllCustomTools: () => ({ data: mockCustomTools }),
useAllWorkflowTools: () => ({ data: mockWorkflowTools }),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
__esModule: true,
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
<div>{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'no-model'}</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: ({
setModel,
onCompletionParamsChange,
}: {
setModel: (model: { provider: string, modelId: string, mode?: string }) => void
onCompletionParamsChange: (params: Record<string, unknown>) => void
}) => (
<div>
<button
type="button"
onClick={() => setModel({ provider: 'anthropic', modelId: 'claude-3-7-sonnet', mode: AppModeEnum.CHAT })}
>
set-model
</button>
<button
type="button"
onClick={() => onCompletionParamsChange({ temperature: 0.2 })}
>
set-params
</button>
</div>
),
}))
vi.mock('@/app/components/base/modal', () => ({
__esModule: true,
default: ({
children,
isShow,
title,
}: {
children: ReactNode
isShow?: boolean
title?: ReactNode
}) => isShow
? (
<div data-testid="base-modal">
<div>{title}</div>
{children}
</div>
)
: null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({
FieldCollapse: ({ title, children }: { title: ReactNode, children: ReactNode }) => (
<div>
<div>{title}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
__esModule: true,
default: ({ title, operations, children }: { title: ReactNode, operations?: ReactNode, children: ReactNode }) => (
<div>
<div>{title}</div>
<div>{operations}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
__esModule: true,
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/config-vision', () => ({
__esModule: true,
default: ({
onEnabledChange,
onConfigChange,
}: {
onEnabledChange: (enabled: boolean) => void
onConfigChange: (value: { variable_selector: string[], detail: string }) => void
}) => (
<div>
<button type="button" onClick={() => onEnabledChange(true)}>vision-toggle</button>
<button type="button" onClick={() => onConfigChange({ variable_selector: ['node-1', 'image'], detail: 'high' })}>vision-config</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({
__esModule: true,
default: ({
onChange,
}: {
onChange: (value: { enabled: boolean }) => void
}) => <button type="button" onClick={() => onChange({ enabled: true })}>memory-config</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
__esModule: true,
default: ({
title,
value,
onChange,
}: {
title: ReactNode
value: string
onChange: (value: string) => void
}) => (
<div>
<div>{typeof title === 'string' ? title : 'editor-title'}</div>
<textarea
aria-label="instruction-editor"
value={value}
onChange={event => onChange(event.target.value)}
/>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
__esModule: true,
default: ({
onChange,
}: {
onChange: (value: string[]) => void
}) => <button type="button" onClick={() => onChange(['node-1', 'query'])}>pick-var</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placeholder', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({
__esModule: true,
default: ({
title,
onSelect,
}: {
title: string
onSelect: () => void
}) => <button type="button" onClick={onSelect}>{title}</button>,
}))
vi.mock('@/app/components/app/configuration/config-var/config-modal/field', () => ({
__esModule: true,
default: ({ title, children }: { title: ReactNode, children: ReactNode }) => (
<div>
<div>{title}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/app/configuration/config-var/config-select', () => ({
__esModule: true,
default: ({
options,
onChange,
}: {
options: string[]
onChange: (value: string[]) => void
}) => (
<div>
<div>{options.join(',')}</div>
<button type="button" onClick={() => onChange([...options, 'published'])}>set-options</button>
</div>
),
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseTextGeneration = vi.mocked(useTextGenerationCurrentProviderAndModelAndModelList)
const mockUseConfig = vi.mocked(useConfig)
const mockToastNotify = vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
const createToolParameter = (overrides: Partial<ToolParameter> = {}): ToolParameter => ({
name: 'city',
label: { en_US: 'City', zh_Hans: '城市' },
human_description: { en_US: 'City input', zh_Hans: '城市输入' },
type: ParamType.string,
form: 'llm',
llm_description: 'City name',
required: true,
multiple: false,
default: '',
options: [
{
value: 'draft',
label: { en_US: 'Draft', zh_Hans: '草稿' },
},
],
...overrides,
})
const createToolInfo = (overrides: Partial<ToolDefaultValue> = {}): ToolDefaultValue => ({
provider_id: 'builtin-1',
provider_type: CollectionType.builtIn,
provider_name: 'builtin',
tool_name: 'search',
tool_label: 'Search',
tool_description: 'Search tool',
title: 'Search',
is_team_authorization: false,
params: {},
paramSchemas: [],
output_schema: {},
...overrides,
})
const createParam = (overrides: Partial<Param> = {}): Param => ({
name: 'city',
type: ParamType.string,
description: 'City name',
required: false,
...overrides,
})
const createData = (overrides: Partial<ParameterExtractorNodeType> = {}): ParameterExtractorNodeType => ({
title: 'Parameter Extractor',
desc: '',
type: BlockEnum.ParameterExtractor,
model: {
provider: 'openai',
name: 'gpt-4o',
mode: AppModeEnum.CHAT,
completion_params: {},
},
query: ['node-1', 'query'],
reasoning_mode: ReasoningModeType.prompt,
parameters: [createParam()],
instruction: 'Extract city and budget',
vision: {
enabled: false,
},
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
handleInputVarChange: vi.fn(),
filterVar: (_varPayload: Var) => true,
isChatMode: true,
inputs: createData(),
isChatModel: true,
isCompletionModel: false,
handleModelChanged: vi.fn(),
handleCompletionParamsChange: vi.fn(),
handleImportFromTool: vi.fn(),
handleExactParamsChange: vi.fn(),
addExtractParameter: vi.fn(),
handleInstructionChange: vi.fn(),
hasSetBlockStatus: { history: false, query: false, context: false },
availableVars: [],
availableNodesWithParent: [],
isSupportFunctionCall: true,
handleReasoningModeChange: vi.fn(),
handleMemoryChange: vi.fn(),
isVisionModel: true,
handleVisionResolutionEnabledChange: vi.fn(),
handleVisionResolutionChange: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('parameter-extractor path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastNotify.mockClear()
mockBuiltInTools = []
mockCustomTools = []
mockWorkflowTools = []
mockSelectedToolInfo = createToolInfo()
mockBlockSelectorOpen = false
mockUseTextGeneration.mockReturnValue({
currentProvider: undefined,
currentModel: undefined,
textGenerationModelList: [],
activeTextGenerationModelList: [],
} as unknown as ReturnType<typeof useTextGenerationCurrentProviderAndModelAndModelList>)
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Tool import and parameter editing', () => {
it('should import llm parameters from the selected tool', async () => {
const user = userEvent.setup()
const onImport = vi.fn()
mockBuiltInTools = [
{
id: 'builtin-1',
tools: [
{
name: 'search',
parameters: [
createToolParameter(),
createToolParameter({
name: 'internal_only',
form: 'form',
}),
],
},
],
},
]
render(<ImportFromTool onImport={onImport} />)
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
expect(onImport).toHaveBeenCalledWith([
{
name: 'city',
type: ParamType.string,
required: true,
description: 'City name',
options: ['Draft'],
},
])
})
it('should ignore invalid tool selections when importing parameters', async () => {
const user = userEvent.setup()
const onImport = vi.fn()
mockSelectedToolInfo = undefined
render(<ImportFromTool onImport={onImport} />)
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
expect(onImport).not.toHaveBeenCalled()
})
it('should import llm parameters from custom and workflow tool collections', async () => {
const user = userEvent.setup()
const onImport = vi.fn()
mockSelectedToolInfo = createToolInfo({
provider_id: 'custom-1',
provider_type: CollectionType.custom,
})
mockCustomTools = [
{
id: 'custom-1',
tools: [
{
name: 'search',
parameters: [createToolParameter({ name: 'custom_city', llm_description: 'Custom city' })],
},
],
},
]
render(<ImportFromTool onImport={onImport} />)
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
expect(onImport).toHaveBeenLastCalledWith([
{
name: 'custom_city',
type: ParamType.string,
required: true,
description: 'Custom city',
options: ['Draft'],
},
])
})
it('should import llm parameters from workflow tool collections', async () => {
const user = userEvent.setup()
const onImport = vi.fn()
mockSelectedToolInfo = createToolInfo({
provider_id: 'workflow-1',
provider_type: CollectionType.workflow,
tool_name: 'transform',
})
mockWorkflowTools = [
{
id: 'workflow-1',
tools: [
{
name: 'transform',
parameters: [createToolParameter({ name: 'workflow_city', llm_description: 'Workflow city' })],
},
],
},
]
render(<ImportFromTool onImport={onImport} />)
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
expect(onImport).toHaveBeenLastCalledWith([
{
name: 'workflow_city',
type: ParamType.string,
required: true,
description: 'Workflow city',
options: ['Draft'],
},
])
})
it('should highlight the trigger when open and return an empty import for unknown providers', async () => {
const user = userEvent.setup()
const onImport = vi.fn()
mockBlockSelectorOpen = true
mockSelectedToolInfo = createToolInfo({
provider_type: 'unknown' as CollectionType,
})
render(<ImportFromTool onImport={onImport} />)
expect(screen.getByText('workflow.nodes.parameterExtractor.importFromTool')).toHaveClass('bg-state-base-hover')
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
expect(onImport).toHaveBeenCalledWith([])
})
it('should show the empty state for an empty parameter list', () => {
render(
<ExtractParameter
readonly={false}
list={[]}
onChange={vi.fn()}
/>,
)
expect(screen.getByText('workflow.nodes.parameterExtractor.extractParametersNotSet')).toBeInTheDocument()
})
it('should edit and delete parameters from the list', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { container, rerender } = render(
<ExtractParameter
readonly={false}
list={[createParam()]}
onChange={onChange}
/>,
)
const editAndDeleteButtons = container.querySelectorAll('.cursor-pointer.rounded-md.p-1')
fireEvent.click(editAndDeleteButtons[0] as HTMLElement)
fireEvent.change(screen.getByDisplayValue('city'), { target: { value: 'city_name' } })
fireEvent.change(screen.getByDisplayValue('City name'), { target: { value: 'Updated city description' } })
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onChange).toHaveBeenCalledWith([
{
name: 'city_name',
type: ParamType.string,
description: 'Updated city description',
required: false,
},
], undefined)
onChange.mockClear()
rerender(
<ExtractParameter
readonly={false}
list={[createParam({ name: 'budget' })]}
onChange={onChange}
/>,
)
const deleteButtons = container.querySelectorAll('.cursor-pointer.rounded-md.p-1')
fireEvent.click(deleteButtons[1] as HTMLElement)
expect(onChange).toHaveBeenCalledWith([])
})
it('should validate required fields before saving an incomplete parameter', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<AddExtractParameter
type="edit"
payload={createParam({
name: '',
description: '',
})}
onSave={onSave}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).not.toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalled()
})
it('should render the add trigger for new parameters', () => {
render(
<AddExtractParameter
type="add"
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('add-button')).toBeInTheDocument()
})
it('should reject invalid names and reset add modal fields after canceling', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(
<AddExtractParameter
type="add"
onSave={vi.fn()}
onCancel={onCancel}
/>,
)
await user.click(screen.getByTestId('add-button'))
const nameInput = screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder')
const descriptionInput = screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder')
fireEvent.change(nameInput, { target: { value: '1bad' } })
expect(mockToastNotify).toHaveBeenCalled()
expect(nameInput).toHaveValue('')
fireEvent.change(nameInput, { target: { value: 'temporary_name' } })
fireEvent.change(descriptionInput, { target: { value: 'Temporary description' } })
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('base-modal')).not.toBeInTheDocument()
await user.click(screen.getByTestId('add-button'))
expect(screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.namePlaceholder')).toHaveValue('')
expect(screen.getByPlaceholderText('workflow.nodes.parameterExtractor.addExtractParameterContent.descriptionPlaceholder')).toHaveValue('')
})
it('should require select options before saving a select parameter', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<AddExtractParameter
type="edit"
payload={createParam({
name: 'status',
type: ParamType.select,
description: 'Status field',
options: [],
})}
onSave={onSave}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).not.toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalled()
})
it('should keep rename metadata and updated options when editing a select parameter', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<AddExtractParameter
type="edit"
payload={createParam({
name: 'status',
type: ParamType.select,
description: 'Status',
options: ['draft'],
})}
onSave={onSave}
/>,
)
fireEvent.change(screen.getByDisplayValue('status'), {
target: { value: 'approval_status' },
})
await user.click(screen.getByRole('button', { name: 'set-options' }))
await user.click(await screen.findByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith({
name: 'approval_status',
type: ParamType.select,
description: 'Status',
options: ['draft', 'published'],
required: false,
}, undefined)
})
it('should persist rename metadata and required state for edited parameters', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<AddExtractParameter
type="edit"
payload={createParam({
name: 'status',
description: 'Status description',
})}
onSave={onSave}
/>,
)
fireEvent.change(screen.getByDisplayValue('status'), {
target: { value: 'approval_status' },
})
await user.click(screen.getByRole('switch'))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith({
name: 'approval_status',
type: ParamType.string,
description: 'Status description',
required: true,
}, undefined)
})
})
describe('Node and panel integration', () => {
it('should let users switch the reasoning mode', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ReasoningModePicker
type={ReasoningModeType.prompt}
onChange={onChange}
/>,
)
await user.click(screen.getByRole('button', { name: 'Function/Tool Calling' }))
await user.click(screen.getByRole('button', { name: 'Prompt' }))
expect(onChange).toHaveBeenNthCalledWith(1, ReasoningModeType.functionCall)
expect(onChange).toHaveBeenNthCalledWith(2, ReasoningModeType.prompt)
})
it('should render the selected model on the node only when configured', () => {
const { rerender } = render(
<Node
id="parameter-node"
data={createData()}
/>,
)
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument()
rerender(
<Node
id="parameter-node"
data={createData({
model: {
provider: '',
name: '',
mode: AppModeEnum.CHAT,
completion_params: {},
},
})}
/>,
)
expect(screen.queryByText('openai:gpt-4o')).not.toBeInTheDocument()
})
it('should wire panel actions across model, input, import, vision, memory, and outputs', async () => {
const user = userEvent.setup()
const handleModelChanged = vi.fn()
const handleCompletionParamsChange = vi.fn()
const handleInputVarChange = vi.fn()
const handleImportFromTool = vi.fn()
const handleInstructionChange = vi.fn()
const handleMemoryChange = vi.fn()
const handleReasoningModeChange = vi.fn()
const handleVisionResolutionEnabledChange = vi.fn()
const handleVisionResolutionChange = vi.fn()
mockBuiltInTools = [
{
id: 'builtin-1',
tools: [
{
name: 'search',
parameters: [createToolParameter()],
},
],
},
]
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
parameters: [createParam({ name: 'city' }), createParam({ name: 'budget', type: ParamType.number })],
}),
handleModelChanged,
handleCompletionParamsChange,
handleInputVarChange,
handleImportFromTool,
handleInstructionChange,
handleMemoryChange,
handleReasoningModeChange,
handleVisionResolutionEnabledChange,
handleVisionResolutionChange,
}))
render(
<Panel
id="parameter-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getByRole('button', { name: 'set-model' }))
await user.click(screen.getByRole('button', { name: 'set-params' }))
await user.click(screen.getByRole('button', { name: 'pick-var' }))
await user.click(screen.getByRole('button', { name: /workflow.nodes.parameterExtractor.importFromTool/i }))
await user.click(screen.getByRole('button', { name: 'vision-toggle' }))
await user.click(screen.getByRole('button', { name: 'vision-config' }))
fireEvent.change(screen.getByLabelText('instruction-editor'), {
target: { value: 'Extract city, budget, and due date' },
})
await user.click(screen.getByRole('button', { name: 'memory-config' }))
await user.click(screen.getByRole('button', { name: 'Function/Tool Calling' }))
expect(handleModelChanged).toHaveBeenCalledWith({
provider: 'anthropic',
modelId: 'claude-3-7-sonnet',
mode: AppModeEnum.CHAT,
})
expect(handleCompletionParamsChange).toHaveBeenCalledWith({ temperature: 0.2 })
expect(handleInputVarChange).toHaveBeenCalledWith(['node-1', 'query'])
expect(handleImportFromTool).toHaveBeenCalledWith([
{
name: 'city',
type: ParamType.string,
required: true,
description: 'City name',
options: ['Draft'],
},
])
expect(handleVisionResolutionEnabledChange).toHaveBeenCalledWith(true)
expect(handleVisionResolutionChange).toHaveBeenCalledWith({
variable_selector: ['node-1', 'image'],
detail: 'high',
})
expect(handleInstructionChange).toHaveBeenCalledWith('Extract city, budget, and due date')
expect(handleMemoryChange).toHaveBeenCalledWith({ enabled: true })
expect(handleReasoningModeChange).toHaveBeenCalledWith(ReasoningModeType.functionCall)
expect(screen.getByText('city:string')).toBeInTheDocument()
expect(screen.getByText('budget:number')).toBeInTheDocument()
expect(screen.getByText('__usage:object')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,385 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { QuestionClassifierNodeType, Topic } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { useEdgesInteractions } from '../../../hooks'
import AdvancedSetting from '../components/advanced-setting'
import ClassItem from '../components/class-item'
import ClassList from '../components/class-list'
import Node from '../node'
import Panel from '../panel'
import useConfig from '../use-config'
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
default: ({ title, value, onChange, onRemove, showRemove, headerClassName }: any) => (
<div className={headerClassName}>
<div>{typeof title === 'string' ? title : 'editor-title'}</div>
<input value={value} onChange={event => onChange(event.target.value)} />
{showRemove && <button type="button" onClick={onRemove}>remove-item</button>}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({
default: ({ onChange }: any) => <button type="button" onClick={() => onChange({ enabled: true })}>memory-config</button>,
}))
vi.mock('../../_base/hooks/use-available-var-list', () => ({
default: vi.fn(() => ({
availableVars: [{ variable: ['node-1', 'answer'], type: VarType.string }],
availableNodesWithParent: [{ id: 'node-1', data: { title: 'Answer', type: BlockEnum.Answer } }],
})),
}))
vi.mock('../../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../hooks')>()
return {
...actual,
useEdgesInteractions: vi.fn(),
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/add-button', () => ({
default: ({ text, onClick }: any) => <button type="button" onClick={onClick}>{text}</button>,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: vi.fn(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel }: any) => <div>{defaultModel.provider}:{defaultModel.model}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/readonly-input-with-select-var', () => ({
default: ({ value }: any) => <div>{value}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/node-handle', () => ({
NodeSourceHandle: ({ handleId }: any) => <div>handle-{handleId}</div>,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
default: ({ setModel, onCompletionParamsChange }: any) => (
<div>
<button type="button" onClick={() => setModel({ provider: 'openai', name: 'gpt-4o' })}>set-model</button>
<button type="button" onClick={() => onCompletionParamsChange({ temperature: 0.2 })}>set-params</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({
FieldCollapse: ({ title, children }: any) => <div><div>{title}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type }: any) => <div>{name}:{type}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/config-vision', () => ({
default: ({ onEnabledChange, onConfigChange }: any) => (
<div>
<button type="button" onClick={() => onEnabledChange(true)}>vision-toggle</button>
<button type="button" onClick={() => onConfigChange({ resolution: 'high' })}>vision-config</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange }: any) => <button type="button" onClick={() => onChange(['node-1', 'query'])}>var-picker</button>,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseEdgesInteractions = vi.mocked(useEdgesInteractions)
const mockUseTextGeneration = vi.mocked(useTextGenerationCurrentProviderAndModelAndModelList)
const mockUseConfig = vi.mocked(useConfig)
const createTopic = (overrides: Partial<Topic> = {}): Topic => ({
id: 'topic-1',
name: 'Billing questions',
...overrides,
})
const createData = (overrides: Partial<QuestionClassifierNodeType> = {}): QuestionClassifierNodeType => ({
title: 'Question Classifier',
desc: '',
type: BlockEnum.QuestionClassifier,
model: {
provider: 'openai',
name: 'gpt-4o',
mode: 'chat',
completion_params: {},
},
classes: [createTopic()],
query_variable_selector: ['node-1', 'query'],
instruction: 'Route by topic',
memory: undefined,
vision: {
enabled: false,
},
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
handleModelChanged: vi.fn(),
isChatMode: true,
isChatModel: true,
handleCompletionParamsChange: vi.fn(),
handleQueryVarChange: vi.fn(),
filterVar: vi.fn(() => true),
handleTopicsChange: vi.fn(),
hasSetBlockStatus: { context: false, history: false, query: false },
availableVars: [],
availableNodesWithParent: [],
availableVisionVars: [],
handleInstructionChange: vi.fn(),
handleMemoryChange: vi.fn(),
isVisionModel: true,
handleVisionResolutionEnabledChange: vi.fn(),
handleVisionResolutionChange: vi.fn(),
handleSortTopic: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
const renderPanel = (data: QuestionClassifierNodeType = createData()) => (
render(<Panel id="node-1" data={data} panelProps={panelProps} />)
)
describe('question-classifier path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseEdgesInteractions.mockReturnValue({
handleEdgeDeleteByDeleteBranch: vi.fn(),
} as unknown as ReturnType<typeof useEdgesInteractions>)
mockUseTextGeneration.mockReturnValue({
currentProvider: undefined,
currentModel: undefined,
textGenerationModelList: [{ provider: 'openai', model: 'gpt-4o', status: 'active' } as any],
activeTextGenerationModelList: [{ provider: 'openai', model: 'gpt-4o', status: 'active' } as any],
})
mockUseConfig.mockReturnValue(createConfigResult())
})
// The question classifier path should wire editor-based classes, model display, and panel controls together.
describe('Path Integration', () => {
it('should render advanced settings and memory config', async () => {
const user = userEvent.setup()
const onInstructionChange = vi.fn()
const onMemoryChange = vi.fn()
render(
<AdvancedSetting
instruction="Route by topic"
onInstructionChange={onInstructionChange}
hideMemorySetting={false}
onMemoryChange={onMemoryChange}
isChatModel
isChatApp
nodesOutputVars={[]}
availableNodes={[]}
/>,
)
await user.type(screen.getByDisplayValue('Route by topic'), '!')
await user.click(screen.getByText('memory-config'))
expect(onInstructionChange).toHaveBeenCalled()
expect(onMemoryChange).toHaveBeenCalledWith({ enabled: true })
})
it('should edit and remove a single class item', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onRemove = vi.fn()
render(
<ClassItem
nodeId="node-1"
payload={createTopic()}
onChange={onChange}
onRemove={onRemove}
index={1}
filterVar={() => true}
/>,
)
await user.type(screen.getByDisplayValue('Billing questions'), ' updated')
await user.click(screen.getByText('remove-item'))
expect(onChange).toHaveBeenCalled()
expect(onRemove).toHaveBeenCalled()
})
it('should add classes and collapse the class list', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { container } = render(
<ClassList
nodeId="node-1"
list={[createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })]}
onChange={onChange}
filterVar={() => true}
/>,
)
await user.click(screen.getByText('workflow.nodes.questionClassifiers.addClass'))
await user.click(screen.getByText('workflow.nodes.questionClassifiers.class'))
expect(screen.queryByText('workflow.nodes.questionClassifiers.addClass')).not.toBeInTheDocument()
await user.click(screen.getByText('workflow.nodes.questionClassifiers.class'))
expect(screen.getByText('workflow.nodes.questionClassifiers.addClass')).toBeInTheDocument()
expect(container.querySelector('.handle')).not.toBeNull()
expect(onChange).toHaveBeenCalled()
})
it('should update and remove classes from the class list and delete the related edge branch', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const handleEdgeDeleteByDeleteBranch = vi.fn()
mockUseEdgesInteractions.mockReturnValueOnce({
handleEdgeDeleteByDeleteBranch,
} as unknown as ReturnType<typeof useEdgesInteractions>)
render(
<ClassList
nodeId="node-1"
list={[createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })]}
onChange={onChange}
filterVar={() => true}
/>,
)
fireEvent.change(screen.getByDisplayValue('Billing questions'), { target: { value: 'Updated billing' } })
await user.click(screen.getAllByText('remove-item')[0]!)
expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ name: 'Updated billing' }),
]))
expect(handleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('node-1', 'topic-1')
})
it('should disable dragging and hide the add button when the class list is readonly', () => {
const { container } = render(
<ClassList
nodeId="node-1"
list={[createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })]}
onChange={vi.fn()}
filterVar={() => true}
readonly
/>,
)
expect(screen.queryByText('workflow.nodes.questionClassifiers.addClass')).not.toBeInTheDocument()
expect(container.querySelector('.handle')).toBeNull()
})
it('should render the node model and output handles for each class', () => {
renderWorkflowFlowComponent(
<Node
id="node-1"
data={createData({ classes: [createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })] })}
type="custom"
selected={false}
zIndex={1}
xPos={0}
yPos={0}
dragging={false}
isConnectable
/>,
{ nodes: [], edges: [] },
)
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument()
expect(screen.getByText('Billing questions')).toBeInTheDocument()
expect(screen.getByText('handle-topic-1')).toBeInTheDocument()
expect(screen.getByText('handle-topic-2')).toBeInTheDocument()
})
it('should render the node when only classes are set and return null when both model and classes are missing', async () => {
const user = userEvent.setup()
const longName = 'L'.repeat(60)
const { rerender } = renderWorkflowFlowComponent(
<Node
id="node-1"
data={createData({
model: { provider: '', name: '', mode: 'chat', completion_params: {} },
classes: [createTopic({ id: 'topic-2', name: longName })],
})}
type="custom"
selected={false}
zIndex={1}
xPos={0}
yPos={0}
dragging={false}
isConnectable
/>,
{ nodes: [], edges: [] },
)
expect(screen.getByText(`${longName.slice(0, 50)}...`)).toBeInTheDocument()
await user.hover(screen.getByText(`${longName.slice(0, 50)}...`))
expect(screen.getByText(longName)).toBeInTheDocument()
rerender(
<Node
id="node-1"
data={createData({
model: { provider: '', name: '', mode: 'chat', completion_params: {} },
classes: [],
})}
type="custom"
selected={false}
zIndex={1}
xPos={0}
yPos={0}
dragging={false}
isConnectable
/>,
)
expect(screen.queryByText('openai:gpt-4o')).not.toBeInTheDocument()
expect(screen.queryByText(`${longName.slice(0, 50)}...`)).not.toBeInTheDocument()
})
it('should render the panel controls and output variables', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByText('set-model'))
await user.click(screen.getByText('set-params'))
await user.click(screen.getAllByText('var-picker')[0]!)
await user.click(screen.getByText('vision-toggle'))
await user.click(screen.getByText('vision-config'))
expect(screen.getByText('class_name:string')).toBeInTheDocument()
expect(screen.getByText('usage:object')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,224 @@
import type { ReactNode } from 'react'
import type { Variable } from '../../../types'
import type { TemplateTransformNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum, VarType } from '../../../types'
import Node from '../node'
import Panel from '../panel'
import useConfig from '../use-config'
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
__esModule: true,
default: ({ title, operations, children }: { title: ReactNode, operations?: ReactNode, children: ReactNode }) => (
<div>
<div>{title}</div>
<div>{operations}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
__esModule: true,
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () => ({
__esModule: true,
default: ({
onChange,
onVarNameChange,
}: {
onChange: (value: Variable[]) => void
onVarNameChange: (oldName: string, newName: string) => void
}) => (
<div>
<button
type="button"
onClick={() => onChange([{
variable: 'updated_input',
value_selector: ['node-1', 'updated_input'],
value_type: VarType.string,
}])}
>
change-var-list
</button>
<button type="button" onClick={() => onVarNameChange('input_text', 'renamed_input')}>
rename-var
</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars', () => ({
__esModule: true,
default: ({
onAddVar,
headerRight,
value,
onChange,
}: {
onAddVar: (value: Variable) => void
headerRight?: ReactNode
value: string
onChange: (value: string) => void
}) => (
<div>
<div>{headerRight}</div>
<button
type="button"
onClick={() => onAddVar({
variable: 'result_text',
value_selector: ['node-2', 'result_text'],
value_type: VarType.string,
})}
>
add-var
</button>
<textarea
aria-label="template-editor"
value={value}
onChange={event => onChange(event.target.value)}
/>
</div>
),
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createVariable = (overrides: Partial<Variable> = {}): Variable => ({
variable: 'input_text',
value_selector: ['node-1', 'input_text'],
value_type: VarType.string,
...overrides,
})
const createData = (overrides: Partial<TemplateTransformNodeType> = {}): TemplateTransformNodeType => ({
title: 'Template Transform',
desc: '',
type: BlockEnum.TemplateTransform,
variables: [createVariable()],
template: '{{ input_text }}',
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
availableVars: [],
handleVarListChange: vi.fn(),
handleVarNameChange: vi.fn(),
handleAddVariable: vi.fn(),
handleAddEmptyVariable: vi.fn(),
handleCodeChange: vi.fn(),
filterVar: () => true,
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('template-transform path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseConfig.mockReturnValue(createConfigResult())
})
it('should render the node shell without summary content', () => {
const { container } = render(
<Node
id="template-node"
data={createData()}
/>,
)
expect(container.firstElementChild).toBeEmptyDOMElement()
})
it('should wire variable list and code editor actions from the panel', async () => {
const user = userEvent.setup()
const handleVarListChange = vi.fn()
const handleVarNameChange = vi.fn()
const handleAddVariable = vi.fn()
const handleAddEmptyVariable = vi.fn()
const handleCodeChange = vi.fn()
mockUseConfig.mockReturnValueOnce(createConfigResult({
handleVarListChange,
handleVarNameChange,
handleAddVariable,
handleAddEmptyVariable,
handleCodeChange,
}))
render(
<Panel
id="template-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getByTestId('add-button'))
await user.click(screen.getByRole('button', { name: 'change-var-list' }))
await user.click(screen.getByRole('button', { name: 'rename-var' }))
await user.click(screen.getByRole('button', { name: 'add-var' }))
fireEvent.change(screen.getByLabelText('template-editor'), { target: { value: '{{ renamed_input }}' } })
expect(handleAddEmptyVariable).toHaveBeenCalled()
expect(handleVarListChange).toHaveBeenCalledWith([
{
variable: 'updated_input',
value_selector: ['node-1', 'updated_input'],
value_type: VarType.string,
},
])
expect(handleVarNameChange).toHaveBeenCalledWith('input_text', 'renamed_input')
expect(handleAddVariable).toHaveBeenCalledWith({
variable: 'result_text',
value_selector: ['node-2', 'result_text'],
value_type: VarType.string,
})
expect(handleCodeChange).toHaveBeenCalledWith('{{ renamed_input }}')
expect(screen.getByText('output:string')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /workflow.nodes.templateTransform.codeSupportTip/i })).toHaveAttribute(
'href',
'https://jinja.palletsprojects.com/en/3.1.x/templates/',
)
})
it('should hide the add-variable operation when the panel is read only', () => {
mockUseConfig.mockReturnValueOnce(createConfigResult({
readOnly: true,
}))
render(
<Panel
id="template-node"
data={createData()}
panelProps={panelProps}
/>,
)
expect(screen.queryByTestId('add-button')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,513 @@
import type { ToolVarInputs } from '../../types'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { App } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import {
ConfigurationMethodEnum,
FormTypeEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType } from '@/app/components/workflow/types'
import { ProviderContext } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
import { VarType as ToolVarType } from '../../types'
import InputVarList from '../input-var-list'
const mockUseAvailableVarList = vi.fn()
const mockFetchNextPage = vi.fn()
const mockApps: App[] = [
{
id: 'app-1',
name: 'Weather Assistant',
mode: AppModeEnum.CHAT,
icon_type: 'emoji',
icon: 'W',
icon_background: '#FFEAD5',
model_config: {
user_input_form: [{
'text-input': {
label: 'Topic',
variable: 'topic',
},
}],
},
} as App,
]
class MockIntersectionObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
root = null
rootMargin = ''
thresholds: number[] = []
takeRecords = vi.fn().mockReturnValue([])
constructor(_callback: IntersectionObserverCallback) {}
}
class MockMutationObserver {
observe = vi.fn()
disconnect = vi.fn()
takeRecords = vi.fn().mockReturnValue([])
constructor(_callback: MutationCallback) {}
}
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
useModelList: () => ({
data: [{
provider: 'openai',
icon_small: {
en_US: 'https://example.com/openai.png',
zh_Hans: 'https://example.com/openai.png',
},
label: {
en_US: 'OpenAI',
zh_Hans: 'OpenAI',
},
models: [{
model: 'gpt-4o',
label: {
en_US: 'GPT-4o',
zh_Hans: 'GPT-4o',
},
model_type: ModelTypeEnum.textGeneration,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {
mode: 'chat',
},
load_balancing_enabled: false,
features: [],
}],
status: ModelStatusEnum.active,
}],
mutate: vi.fn(),
isLoading: false,
}),
useMarketplaceAllPlugins: () => ({
plugins: [],
isLoading: false,
}),
useUpdateModelList: () => vi.fn(),
useUpdateModelProviders: () => vi.fn(),
useCurrentProviderAndModel: (
modelList: Array<{
provider: string
models: Array<{ model: string }>
}>,
defaultModel?: { provider: string, model: string },
) => {
const currentProvider = modelList.find(provider => provider.provider === defaultModel?.provider)
const currentModel = currentProvider?.models.find(model => model.model === defaultModel?.model)
return {
currentProvider,
currentModel,
}
},
}))
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: {
pages: [{
data: mockApps,
}],
},
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
}),
useAppDetail: (appId: string) => ({
data: mockApps.find(app => app.id === appId),
isFetching: false,
}),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({
data: undefined,
isFetching: false,
}),
}))
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: {
image_file_size_limit: 10,
file_size_limit: 15,
audio_file_size_limit: 50,
video_file_size_limit: 100,
workflow_file_upload_limit: 10,
},
}),
useModelParameterRules: () => ({
data: {
data: [],
},
isPending: false,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseAvailableVarList(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({
default: ({
value,
onChange,
onFocusChange,
placeholder,
}: {
value: string
onChange: (value: string) => void
onFocusChange?: (value: boolean) => void
placeholder?: string
}) => (
<input
aria-label={placeholder || 'mixed-input'}
value={value}
onFocus={() => onFocusChange?.(true)}
onBlur={() => onFocusChange?.(false)}
onChange={e => onChange(e.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({
onChange,
onOpen,
schema,
defaultVarKindType,
}: {
onChange: (value: string[] | string, kind: ToolVarType) => void
onOpen?: () => void
schema?: { variable?: string }
defaultVarKindType?: ToolVarType
}) => (
<button
type="button"
onClick={() => {
onOpen?.()
if (defaultVarKindType === ToolVarType.variable)
onChange(['node-1', 'file'], ToolVarType.variable)
else
onChange('42', defaultVarKindType || ToolVarType.constant)
}}
>
{`pick-${schema?.variable || 'var'}`}
</button>
),
}))
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: {
trial_models: [],
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-trial-credits', () => ({
useTrialCredits: () => ({
isExhausted: false,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-change-provider-priority', () => ({
useChangeProviderPriority: () => ({
isChangingPriority: false,
handleChangePriority: vi.fn(),
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: () => ({
variant: 'api-active',
priority: 'apiKeyOnly',
supportsCredits: false,
showPrioritySwitcher: false,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'Primary key',
credits: 0,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth/hooks', () => ({
useCredentialStatus: () => ({
hasCredential: true,
authorized: true,
current_credential_name: 'Primary key',
}),
}))
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
default: () => ({
check: vi.fn(),
}),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
default: () => ({
refreshPluginList: vi.fn(),
}),
}))
vi.mock('@/service/use-plugins', () => ({
useInstallPackageFromMarketPlace: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/utils/completion-params', () => ({
fetchAndMergeValidCompletionParams: vi.fn(async () => ({
params: {},
removedDetails: {},
})),
}))
const createSchemaItem = (
variable: string,
type: FormTypeEnum,
overrides: Partial<CredentialFormSchema> = {},
): CredentialFormSchema => ({
variable,
name: variable,
label: {
en_US: `${variable}-label`,
zh_Hans: `${variable}-label`,
},
type,
required: false,
show_on: [],
...overrides,
})
type TestHarnessProps = {
schema: CredentialFormSchema[]
initialValue?: ToolVarInputs
onChangeSpy?: (value: ToolVarInputs) => void
onOpen?: (index: number) => void
}
const TestHarness = ({
schema,
initialValue = {},
onChangeSpy,
onOpen,
}: TestHarnessProps) => {
const [value, setValue] = useState<ToolVarInputs>(initialValue)
return (
<InputVarList
readOnly={false}
nodeId="tool-node"
schema={schema}
value={value}
onChange={(nextValue) => {
setValue(nextValue)
onChangeSpy?.(nextValue)
}}
onOpen={onOpen}
/>
)
}
const renderInputVarList = (ui: React.ReactElement) => {
const providerContextValue = createMockProviderContextValue({
isAPIKeySet: true,
modelProviders: [{
provider: 'openai',
label: {
en_US: 'OpenAI',
zh_Hans: 'OpenAI',
},
preferred_provider_type: 'custom',
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
supported_model_types: [ModelTypeEnum.textGeneration],
}] as ReturnType<typeof createMockProviderContextValue>['modelProviders'],
})
return render(
<ProviderContext.Provider value={providerContextValue}>
{ui}
</ProviderContext.Provider>,
)
}
describe('InputVarList', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
vi.stubGlobal('MutationObserver', MockMutationObserver)
})
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableVarList.mockReturnValue({
availableVars: [{
nodeId: 'node-1',
title: 'Node 1',
vars: [{ variable: 'score', type: VarType.number }],
}],
availableNodesWithParent: [],
})
})
afterAll(() => {
vi.unstubAllGlobals()
})
it('should render schema labels and update mixed text inputs', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderInputVarList(
<TestHarness
schema={[
createSchemaItem('query', FormTypeEnum.textInput, {
required: true,
tooltip: {
en_US: 'query-tip',
zh_Hans: 'query-tip',
},
}),
]}
onChangeSpy={onChange}
/>,
)
expect(screen.getByText('query-label')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('Required')).toBeInTheDocument()
expect(screen.getByText('query-tip')).toBeInTheDocument()
await user.type(screen.getByLabelText('workflow.nodes.http.insertVarPlaceholder'), 'hello')
expect(onChange).toHaveBeenLastCalledWith({
query: {
type: ToolVarType.mixed,
value: 'hello',
},
})
})
it('should transform variable picker selections for number and file fields and report picker openings', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onOpen = vi.fn()
renderInputVarList(
<TestHarness
schema={[
createSchemaItem('limit', FormTypeEnum.textNumber),
createSchemaItem('attachment', FormTypeEnum.file),
]}
onOpen={onOpen}
onChangeSpy={onChange}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-limit' }))
await user.click(screen.getByRole('button', { name: 'pick-var' }))
expect(onOpen).toHaveBeenNthCalledWith(1, 0)
expect(onOpen).toHaveBeenNthCalledWith(2, 1)
expect(onChange).toHaveBeenNthCalledWith(1, {
limit: {
type: ToolVarType.constant,
value: '42',
},
})
expect(onChange).toHaveBeenNthCalledWith(2, {
limit: {
type: ToolVarType.constant,
value: '42',
},
attachment: {
type: ToolVarType.variable,
value: ['node-1', 'file'],
},
})
})
it('should replace app selections and merge model selections into existing values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderInputVarList(
<TestHarness
schema={[
createSchemaItem('assistant', FormTypeEnum.appSelector),
createSchemaItem('model', FormTypeEnum.modelSelector, {
scope: 'llm',
}),
]}
initialValue={{
model: {
credential_id: 'credential-1',
},
} as unknown as ToolVarInputs}
onChangeSpy={onChange}
/>,
)
await user.click(screen.getAllByText('app.appSelector.placeholder')[0]!)
await user.click(screen.getAllByText('app.appSelector.placeholder')[1]!)
await user.click(screen.getByTitle('Weather Assistant (app-1)'))
await user.type(screen.getByPlaceholderText('Topic'), 'weather')
expect(onChange).toHaveBeenNthCalledWith(1, {
assistant: {
app_id: 'app-1',
inputs: {},
files: [],
},
model: {
credential_id: 'credential-1',
},
})
expect(onChange).toHaveBeenLastCalledWith({
assistant: {
app_id: 'app-1',
inputs: { topic: 'weather' },
files: [],
},
model: {
credential_id: 'credential-1',
},
})
await user.click(screen.getByText('workflow:errorMsg.configureModel'))
await user.click(await screen.findByRole('button', { name: 'plugin.detailPanel.configureModel' }))
await user.click(await screen.findByRole('button', { name: /GPT-4o/i }))
expect(onChange).toHaveBeenLastCalledWith({
assistant: {
app_id: 'app-1',
inputs: { topic: 'weather' },
files: [],
},
model: {
completion_params: {},
credential_id: 'credential-1',
mode: 'chat',
provider: 'openai',
model: 'gpt-4o',
model_type: 'llm',
},
})
})
})

View File

@ -0,0 +1,266 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { ScheduleTriggerNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Panel from '../panel'
import useConfig from '../use-config'
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => (
<div>
<div>{title}</div>
<div>{operations}</div>
<div>{children}</div>
</div>
),
}))
vi.mock('../components/frequency-selector', () => ({
default: ({ frequency, onChange }: any) => (
<button type="button" onClick={() => onChange('weekly')}>
{frequency}
</button>
),
}))
vi.mock('../components/mode-toggle', () => ({
default: ({ mode, onChange }: any) => (
<button type="button" onClick={() => onChange(mode === 'visual' ? 'cron' : 'visual')}>
{mode}
</button>
),
}))
vi.mock('../components/monthly-days-selector', () => ({
default: ({ onChange }: any) => (
<button type="button" onClick={() => onChange([1, 'last'])}>
monthly-days
</button>
),
}))
vi.mock('../components/next-execution-times', () => ({
default: ({ data }: any) => <div>next-times-{data.mode}</div>,
}))
vi.mock('../components/on-minute-selector', () => ({
default: ({ onChange }: any) => (
<button type="button" onClick={() => onChange(25)}>
minute-selector
</button>
),
}))
vi.mock('../components/weekday-selector', () => ({
default: ({ onChange }: any) => (
<button type="button" onClick={() => onChange(['mon', 'wed'])}>
weekday-selector
</button>
),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
title: 'Schedule Trigger',
desc: '',
type: 'trigger-schedule' as ScheduleTriggerNodeType['type'],
mode: 'visual',
frequency: 'daily',
timezone: 'UTC',
visual_config: {
time: '11:30 AM',
weekdays: ['mon'],
on_minute: 15,
monthly_days: [1],
},
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
const renderPanel = (id: string, data: ScheduleTriggerNodeType) => (
render(<Panel id={id} data={data} panelProps={panelProps} />)
)
describe('TriggerSchedulePanel', () => {
const setInputs = vi.fn()
const handleModeChange = vi.fn()
const handleFrequencyChange = vi.fn()
const handleCronExpressionChange = vi.fn()
const handleWeekdaysChange = vi.fn()
const handleTimeChange = vi.fn()
const handleOnMinuteChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseConfig.mockReturnValue({
readOnly: false,
inputs: createData(),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
})
// The panel should wire the visual and cron controls back to the schedule config handlers.
describe('Panel Wiring', () => {
it('should render the visual controls and forward their callbacks', async () => {
const user = userEvent.setup()
renderPanel('node-1', createData())
await user.click(screen.getByRole('button', { name: 'visual' }))
await user.click(screen.getByRole('button', { name: 'daily' }))
await user.click(screen.getByDisplayValue('11:30 AM'))
await user.click(screen.getAllByText('02')[0]!)
await user.click(screen.getByText('45'))
await user.click(screen.getByText('PM'))
await user.click(screen.getByRole('button', { name: /operation\.ok/i }))
expect(handleModeChange).toHaveBeenCalledWith('cron')
expect(handleFrequencyChange).toHaveBeenCalledWith('weekly')
expect(handleTimeChange).toHaveBeenCalledWith('2:45 PM')
expect(screen.getByText('next-times-visual')).toBeInTheDocument()
})
it('should render weekday and monthly helpers for the matching frequencies', async () => {
const user = userEvent.setup()
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ frequency: 'weekly' }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
renderPanel('node-1', createData({ frequency: 'weekly' }))
await user.click(screen.getByRole('button', { name: 'weekday-selector' }))
expect(handleWeekdaysChange).toHaveBeenCalledWith(['mon', 'wed'])
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ frequency: 'weekly', visual_config: undefined as any }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
renderPanel('node-5', createData({ frequency: 'weekly', visual_config: undefined as any }))
await user.click(screen.getAllByRole('button', { name: 'weekday-selector' })[1]!)
expect(handleWeekdaysChange).toHaveBeenCalledTimes(2)
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ frequency: 'monthly', visual_config: undefined as any }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
renderPanel('node-2', createData({ frequency: 'monthly', visual_config: undefined as any }))
await user.click(screen.getByRole('button', { name: 'monthly-days' }))
expect(setInputs).toHaveBeenCalled()
})
it('should render cron mode and forward expression changes', () => {
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ mode: 'cron', frequency: undefined, cron_expression: '0 0 * * *' }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
renderPanel('node-3', createData({ mode: 'cron' }))
fireEvent.change(screen.getByDisplayValue('0 0 * * *'), { target: { value: '*/5 * * * *' } })
expect(handleCronExpressionChange).toHaveBeenCalledWith('*/5 * * * *')
})
it('should use daily and empty cron defaults when the schedule values are missing', () => {
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ frequency: undefined }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
const { rerender } = renderPanel('node-6', createData({ frequency: undefined }) as any)
expect(screen.getByRole('button', { name: 'daily' })).toBeInTheDocument()
expect(screen.getByDisplayValue('11:30 AM')).toBeInTheDocument()
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ mode: 'cron', frequency: undefined, cron_expression: undefined as any }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
rerender(<Panel id="node-7" data={createData({ mode: 'cron', frequency: undefined, cron_expression: undefined as any }) as any} panelProps={panelProps} />)
expect(screen.getByRole('textbox')).toHaveValue('')
})
it('should render the hourly minute selector when the frequency is hourly', async () => {
const user = userEvent.setup()
mockUseConfig.mockReturnValueOnce({
readOnly: false,
inputs: createData({ frequency: 'hourly' }),
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleOnMinuteChange,
})
renderPanel('node-4', createData({ frequency: 'hourly' }))
await user.click(screen.getByRole('button', { name: 'minute-selector' }))
expect(handleOnMinuteChange).toHaveBeenCalledWith(25)
})
})
})

View File

@ -0,0 +1,151 @@
/* eslint-disable ts/no-explicit-any */
import type { ScheduleTriggerNodeType } from '../../types'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import FrequencySelector from '../frequency-selector'
import ModeSwitcher from '../mode-switcher'
import ModeToggle from '../mode-toggle'
import MonthlyDaysSelector from '../monthly-days-selector'
import NextExecutionTimes from '../next-execution-times'
import OnMinuteSelector from '../on-minute-selector'
import WeekdaySelector from '../weekday-selector'
const createData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
title: 'Schedule Trigger',
desc: '',
type: 'trigger-schedule' as ScheduleTriggerNodeType['type'],
mode: 'visual',
frequency: 'daily',
timezone: 'UTC',
visual_config: {
time: '11:30 AM',
weekdays: ['mon'],
on_minute: 15,
monthly_days: [1],
},
...overrides,
})
describe('trigger-schedule components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The leaf controls should expose schedule actions and derived previews for the visual scheduler.
describe('Leaf Rendering', () => {
it('should select a new frequency from the dropdown options', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FrequencySelector
frequency="daily"
onChange={onChange}
/>,
)
const trigger = screen.getByRole('button', { name: 'workflow.nodes.triggerSchedule.frequency.daily' })
fireEvent.click(trigger)
await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
const listbox = await screen.findByRole('listbox')
await user.click(within(listbox).getByText('workflow.nodes.triggerSchedule.frequency.weekly'))
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('weekly')
})
})
it('should switch between visual and cron modes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ModeSwitcher mode="visual" onChange={onChange} />)
await user.click(screen.getByText('workflow.nodes.triggerSchedule.modeCron'))
expect(onChange).toHaveBeenCalledWith('cron')
})
it('should toggle the mode from visual to cron', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ModeToggle mode="visual" onChange={onChange} />)
await user.click(screen.getByRole('button'))
expect(onChange).toHaveBeenCalledWith('cron')
})
it('should toggle the mode from cron back to visual', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ModeToggle mode="cron" onChange={onChange} />)
await user.click(screen.getByRole('button'))
expect(onChange).toHaveBeenCalledWith('visual')
})
it('should change the hourly minute through the slider', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<OnMinuteSelector value={15} onChange={onChange} />)
const slider = screen.getByRole('slider')
slider.focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith(16, 0)
})
it('should keep at least one weekday selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<WeekdaySelector selectedDays={['mon']} onChange={onChange} />)
await user.click(screen.getByRole('button', { name: 'Mon' }))
expect(onChange).toHaveBeenCalledWith(['mon'])
})
it('should add a new weekday when the day is not selected yet', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<WeekdaySelector selectedDays={[]} onChange={onChange} />)
await user.click(screen.getByRole('button', { name: 'Tue' }))
expect(onChange).toHaveBeenCalledWith(['tue'])
})
it('should toggle monthly days and show the day-31 warning', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<MonthlyDaysSelector selectedDays={[31]} onChange={onChange} />)
expect(screen.getByText('workflow.nodes.triggerSchedule.lastDayTooltip')).toBeInTheDocument()
await user.click(screen.getByText('workflow.nodes.triggerSchedule.lastDay'))
expect(onChange).toHaveBeenCalled()
})
it('should render the upcoming execution times when the schedule is valid', () => {
render(<NextExecutionTimes data={createData()} />)
expect(screen.getByText('workflow.nodes.triggerSchedule.nextExecutionTimes')).toBeInTheDocument()
expect(screen.getAllByText(/^\d{2}$/).length).toBeGreaterThan(0)
})
it('should hide upcoming execution times when frequency is missing or cron is invalid', () => {
const { rerender, container } = render(<NextExecutionTimes data={createData({ frequency: undefined }) as any} />)
expect(container).toBeEmptyDOMElement()
rerender(<NextExecutionTimes data={createData({ mode: 'cron', cron_expression: 'bad cron' }) as any} />)
expect(container).toBeEmptyDOMElement()
})
})
})

View File

@ -0,0 +1,537 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { VariableAssignerNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast from '@/app/components/base/toast'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import AddVariable from '../components/add-variable'
import NodeGroupItem from '../components/node-group-item'
import NodeVariableItem from '../components/node-variable-item'
import VarGroupItem from '../components/var-group-item'
import VarList from '../components/var-list'
import Panel from '../panel'
import useConfig from '../use-config'
const mockHandleAssignVariableValueChange = vi.fn()
const mockHandleGroupItemMouseEnter = vi.fn()
const mockHandleGroupItemMouseLeave = vi.fn()
const mockGetAvailableVars = vi.fn()
vi.mock('@/app/components/workflow/nodes/_base/components/add-variable-popup', () => ({
default: ({ onSelect }: any) => (
<button
type="button"
onClick={() => onSelect(['source-node', 'pickedVar'], { variable: 'pickedVar', type: VarType.string })}
>
confirm-add-variable
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ value, onChange, isAddBtnTrigger, onOpen, placeholder }: any) => (
<div>
<div>{Array.isArray(value) ? value.join('.') : ''}</div>
<button
type="button"
onClick={() => {
onOpen?.()
if (isAddBtnTrigger)
onChange(['source-node', 'groupVar'], 'variable', { variable: 'groupVar', type: VarType.string })
else
onChange(['source-node', 'updatedVar'])
}}
>
{isAddBtnTrigger ? 'add-variable-from-picker' : (placeholder || 'pick-var')}
</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/remove-button', () => ({
default: ({ onClick }: any) => <button type="button" onClick={onClick}>remove-variable</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children, className }: any) => <div className={className}><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type, description }: any) => <div>{`${name}:${type}:${description}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm', () => ({
default: ({ isShow, onCancel, onConfirm }: any) => isShow
? (
<div>
<button type="button" onClick={onCancel}>cancel-remove</button>
<button type="button" onClick={onConfirm}>confirm-remove</button>
</div>
)
: null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInNode: ({ variables, nodeTitle, isExceptionVariable }: any) => (
<div>{`${nodeTitle}:${variables.join('.')}:${String(Boolean(isExceptionVariable))}`}</div>
),
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
VarBlockIcon: ({ type }: any) => <div>{`block-icon:${type}`}</div>,
}))
vi.mock('../hooks', () => ({
useVariableAssigner: () => ({
handleAssignVariableValueChange: mockHandleAssignVariableValueChange,
handleGroupItemMouseEnter: mockHandleGroupItemMouseEnter,
handleGroupItemMouseLeave: mockHandleGroupItemMouseLeave,
}),
useGetAvailableVars: () => mockGetAvailableVars,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<VariableAssignerNodeType> = {}): VariableAssignerNodeType => ({
title: 'Variable Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
output_type: VarType.string,
variables: [['source-node', 'initialVar']],
advanced_settings: {
group_enabled: true,
groups: [
{
groupId: 'group-1',
group_name: 'Group1',
output_type: VarType.string,
variables: [['source-node', 'initialVar']],
},
{
groupId: 'group-2',
group_name: 'Group2',
output_type: VarType.number,
variables: [],
},
],
},
selected: false,
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
handleListOrTypeChange: vi.fn(),
isEnableGroup: true,
handleGroupEnabledChange: vi.fn(),
handleAddGroup: vi.fn(),
handleListOrTypeChangeInGroup: vi.fn(() => vi.fn()),
handleGroupRemoved: vi.fn(() => vi.fn()),
handleVarGroupNameChange: vi.fn(() => vi.fn()),
isShowRemoveVarConfirm: false,
hideRemoveVarConfirm: vi.fn(),
onRemoveVarConfirm: vi.fn(),
getAvailableVars: vi.fn(() => []),
filterVar: vi.fn(() => vi.fn(() => true)),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('variable-assigner path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetAvailableVars.mockReturnValue([
{
nodeId: 'source-node',
title: 'Source Node',
vars: [{ variable: 'pickedVar', type: VarType.string }],
},
])
mockUseConfig.mockReturnValue(createConfigResult())
vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
})
describe('Path Integration', () => {
it('should open the add-variable popup and assign a selected value', async () => {
const user = userEvent.setup()
const { container } = render(
<AddVariable
availableVars={[]}
variableAssignerNodeId="assigner-node"
variableAssignerNodeData={createData({ selected: true })}
handleId="group-1"
/>,
)
await user.click(container.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement)
await user.click(screen.getByRole('button', { name: 'confirm-add-variable' }))
expect(mockHandleAssignVariableValueChange).toHaveBeenCalledWith(
'assigner-node',
['source-node', 'pickedVar'],
{ variable: 'pickedVar', type: VarType.string },
'group-1',
)
})
it('should render node variable labels for env, system, and rag variables', () => {
const node = {
id: 'source-node',
data: { title: 'Source Node', type: BlockEnum.Answer },
} as any
const { rerender, container } = render(
<NodeVariableItem
node={node}
variable={['env', 'API_KEY']}
writeMode="append"
/>,
)
expect(container).toHaveTextContent('Source Node')
expect(container).toHaveTextContent('API_KEY')
expect(container).toHaveTextContent('workflow.nodes.assigner.operations.append')
rerender(
<NodeVariableItem
node={node}
variable={['sys', 'query']}
isException
/>,
)
expect(container).toHaveTextContent('sys.query')
rerender(
<NodeVariableItem
node={node}
variable={['rag', 'metadata']}
/>,
)
expect(container).toHaveTextContent('metadata')
})
it('should render, update, and remove variables in the list', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onOpen = vi.fn()
const { rerender } = render(
<VarList
readonly={false}
nodeId="assigner-node"
list={[]}
onChange={onChange}
/>,
)
expect(screen.getByText('workflow.nodes.variableAssigner.noVarTip')).toBeInTheDocument()
rerender(
<VarList
readonly={false}
nodeId="assigner-node"
list={[['source-node', 'initialVar']]}
onChange={onChange}
onOpen={onOpen}
filterVar={vi.fn(() => true)}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-var' }))
expect(onOpen).toHaveBeenCalledWith(0)
expect(onChange).toHaveBeenLastCalledWith([['source-node', 'updatedVar']], ['source-node', 'updatedVar'])
await user.click(screen.getByRole('button', { name: 'remove-variable' }))
expect(onChange).toHaveBeenLastCalledWith([])
})
it('should add group variables, validate group names, and allow removing the group', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onGroupNameChange = vi.fn()
const onRemove = vi.fn()
const { container } = render(
<VarGroupItem
readOnly={false}
nodeId="assigner-node"
payload={{
group_name: 'Group1',
output_type: VarType.any,
variables: [],
}}
onChange={onChange}
groupEnabled
onGroupNameChange={onGroupNameChange}
canRemove
onRemove={onRemove}
availableVars={[]}
/>,
)
await user.click(screen.getByRole('button', { name: 'add-variable-from-picker' }))
expect(onChange).toHaveBeenCalledWith({
group_name: 'Group1',
output_type: VarType.string,
variables: [['source-node', 'groupVar']],
})
await user.click(screen.getByText('Group1'))
fireEvent.change(screen.getByDisplayValue('Group1'), { target: { value: '1bad' } })
expect(Toast.notify).toHaveBeenCalled()
fireEvent.change(screen.getByDisplayValue('Group1'), { target: { value: 'Renamed Group' } })
expect(onGroupNameChange).toHaveBeenCalledWith('Renamed_Group')
await user.click(container.querySelector('.cursor-pointer.rounded-md') as HTMLElement)
expect(onRemove).toHaveBeenCalledTimes(1)
})
it('should ignore duplicate group variables and reset the output type when the group becomes empty', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<VarGroupItem
readOnly={false}
nodeId="assigner-node"
payload={{
group_name: 'Group1',
output_type: VarType.string,
variables: [['source-node', 'groupVar']],
}}
onChange={onChange}
groupEnabled
availableVars={[]}
/>,
)
await user.click(screen.getByRole('button', { name: 'add-variable-from-picker' }))
expect(onChange).not.toHaveBeenCalled()
rerender(
<VarGroupItem
readOnly={false}
nodeId="assigner-node"
payload={{
group_name: 'Group1',
output_type: VarType.string,
variables: [['source-node', 'updatedVar']],
}}
onChange={onChange}
groupEnabled
availableVars={[]}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-var' }))
expect(onChange).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'remove-variable' }))
expect(onChange).toHaveBeenLastCalledWith({
group_name: 'Group1',
output_type: VarType.any,
variables: [],
})
rerender(
<VarGroupItem
readOnly
nodeId="assigner-node"
payload={{
output_type: VarType.any,
variables: [],
}}
onChange={onChange}
groupEnabled={false}
availableVars={[]}
/>,
)
expect(screen.getByText('workflow.nodes.variableAssigner.title')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'add-variable-from-picker' })).not.toBeInTheDocument()
})
it('should render empty and populated node groups with hover states', async () => {
const user = userEvent.setup()
const selectedData = createData()
const { container, rerender } = renderWorkflowFlowComponent(
<NodeGroupItem
item={{
groupEnabled: true,
targetHandleId: 'group-1',
title: 'Group1',
type: 'string',
variables: [],
variableAssignerNodeId: 'assigner-node',
variableAssignerNodeData: selectedData,
}}
/>,
{
nodes: [
{ id: 'source-node', position: { x: 0, y: 0 }, data: { title: 'Source Node', type: BlockEnum.Answer } as any },
],
edges: [],
initialStoreState: {
enteringNodePayload: {
nodeId: 'assigner-node',
nodeData: selectedData,
} as any,
hoveringAssignVariableGroupId: 'group-1',
},
},
)
expect(container).toHaveTextContent('workflow.nodes.variableAssigner.varNotSet')
const groupCard = container.querySelector('.relative.rounded-lg') as HTMLElement
expect(groupCard).toHaveClass('!border-text-accent')
fireEvent.mouseEnter(groupCard)
fireEvent.mouseLeave(groupCard)
expect(mockHandleGroupItemMouseEnter).toHaveBeenCalledWith('group-1')
expect(mockHandleGroupItemMouseLeave).toHaveBeenCalledTimes(1)
rerender(
<NodeGroupItem
item={{
groupEnabled: true,
targetHandleId: 'group-2',
title: 'Group2',
type: 'string',
variables: [['source-node', 'initialVar']],
variableAssignerNodeId: 'assigner-node',
variableAssignerNodeData: selectedData,
}}
/>,
)
expect(container).toHaveTextContent('Source Node:source-node.initialVar:false')
expect(container.querySelector('.relative.rounded-lg')).toHaveClass('!border-dashed')
await user.click(container.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement)
await user.click(screen.getByRole('button', { name: 'confirm-add-variable' }))
expect(mockHandleAssignVariableValueChange).toHaveBeenCalled()
})
it('should resolve default group borders without an active hover id and render exception variables', () => {
const selectedData = createData()
const { container, rerender } = renderWorkflowFlowComponent(
<NodeGroupItem
item={{
groupEnabled: true,
targetHandleId: 'group-2',
title: 'Group2',
type: 'string',
variables: [],
variableAssignerNodeId: 'assigner-node',
variableAssignerNodeData: selectedData,
}}
/>,
{
nodes: [
{ id: 'agent-node', position: { x: 0, y: 0 }, data: { title: 'Agent Node', type: BlockEnum.Agent } as any },
],
edges: [],
initialStoreState: {
enteringNodePayload: {
nodeId: 'assigner-node',
nodeData: selectedData,
} as any,
hoveringAssignVariableGroupId: undefined,
},
},
)
expect(container.querySelector('.relative.rounded-lg')).toHaveClass('!border-dashed')
rerender(
<NodeGroupItem
item={{
groupEnabled: false,
targetHandleId: 'target',
title: 'Target',
type: 'string',
variables: [['agent-node', 'error_message']],
variableAssignerNodeId: 'assigner-node',
variableAssignerNodeData: createData({
output_type: VarType.any,
variables: [['agent-node', 'error_message']],
}),
}}
/>,
)
expect(container).toHaveTextContent('Agent Node:agent-node.error_message:true')
})
it('should render grouped and ungrouped panels and confirm removal actions', async () => {
const user = userEvent.setup()
const groupedConfig = createConfigResult({
isShowRemoveVarConfirm: true,
})
mockUseConfig.mockReturnValue(groupedConfig)
const { rerender } = render(
<Panel
id="assigner-node"
data={createData()}
panelProps={panelProps}
/>,
)
expect(screen.getByText('Group1.output:string:workflow.nodes.variableAssigner.outputVars.varDescribe:{"groupName":"Group1"}')).toBeInTheDocument()
expect(screen.getByText('Group2.output:number:workflow.nodes.variableAssigner.outputVars.varDescribe:{"groupName":"Group2"}')).toBeInTheDocument()
await user.click(screen.getByRole('switch'))
expect(groupedConfig.handleGroupEnabledChange).toHaveBeenCalled()
await user.click(screen.getByText('workflow.nodes.variableAssigner.addGroup'))
expect(groupedConfig.handleAddGroup).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'cancel-remove' }))
expect(groupedConfig.hideRemoveVarConfirm).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'confirm-remove' }))
expect(groupedConfig.onRemoveVarConfirm).toHaveBeenCalledTimes(1)
const singleConfig = createConfigResult({
isEnableGroup: false,
inputs: createData({
advanced_settings: {
group_enabled: false,
groups: [],
},
}),
})
mockUseConfig.mockReturnValue(singleConfig)
rerender(
<Panel
id="assigner-node"
data={singleConfig.inputs}
panelProps={panelProps}
/>,
)
expect(screen.queryByText('Group1.output:string:workflow.nodes.variableAssigner.outputVars.varDescribe:{"groupName":"Group1"}')).not.toBeInTheDocument()
expect(screen.getByText('workflow.nodes.variableAssigner.aggregationGroup')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,162 @@
import type { HumanInputFormData } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CUSTOM_NODE } from '@/app/components/workflow/constants'
import { DeliveryMethodType, UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType } from '@/app/components/workflow/types'
import HumanInputFormList from '../human-input-form-list'
const mockNodes: Array<{
id: string
type: string
data: {
delivery_methods: Array<Record<string, unknown>>
}
}> = []
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: () => mockNodes,
}),
}),
}))
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (state: { userProfile: { email: string } }) => T) => selector({
userProfile: { email: 'debug@example.com' },
}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInputFormData => ({
form_id: 'form-1',
node_id: 'human-node-1',
node_title: 'Need Approval',
form_content: 'Before {{#$output.reason#}} after',
inputs: [{
type: InputVarType.paragraph,
output_variable_name: 'reason',
default: {
selector: [],
type: 'constant',
value: 'prefill',
},
}],
actions: [{
id: 'approve',
title: 'Approve',
button_style: UserActionButtonType.Primary,
}],
form_token: 'token-1',
resolved_default_values: {},
display_in_ui: true,
expiration_time: 2_000_000_000,
...overrides,
})
describe('HumanInputFormList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodes.splice(0, mockNodes.length)
})
it('should render only visible forms, derive delivery method tips, and submit updated inputs', async () => {
const user = userEvent.setup()
const onHumanInputFormSubmit = vi.fn().mockResolvedValue(undefined)
mockNodes.push(
{
id: 'human-node-1',
type: CUSTOM_NODE,
data: {
delivery_methods: [{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: true,
config: {
recipients: {
whole_workspace: false,
items: [],
},
subject: 'Need approval',
body: 'Please review',
debug_mode: true,
},
}],
},
},
{
id: 'human-node-2',
type: CUSTOM_NODE,
data: {
delivery_methods: [],
},
},
)
render(
<HumanInputFormList
humanInputFormDataList={[
createFormData(),
createFormData({
form_id: 'form-2',
node_id: 'human-node-2',
node_title: 'Hidden Form',
display_in_ui: false,
}),
]}
onHumanInputFormSubmit={onHumanInputFormSubmit}
/>,
)
expect(screen.getByText('Need Approval')).toBeInTheDocument()
expect(screen.queryByText('Hidden Form')).not.toBeInTheDocument()
expect(screen.getByDisplayValue('prefill')).toBeInTheDocument()
expect(screen.getByTestId('expiration-time')).toBeInTheDocument()
expect(screen.getByTestId('tips')).toBeInTheDocument()
await user.clear(screen.getByDisplayValue('prefill'))
await user.type(screen.getByTestId('content-item-textarea'), 'updated reason')
await user.click(screen.getByRole('button', { name: 'Approve' }))
expect(onHumanInputFormSubmit).toHaveBeenCalledWith('token-1', {
inputs: {
reason: 'updated reason',
},
action: 'approve',
})
})
it('should omit delivery tips when the node has no enabled delivery methods', () => {
mockNodes.push({
id: 'human-node-1',
type: CUSTOM_NODE,
data: {
delivery_methods: [],
},
})
render(
<HumanInputFormList
humanInputFormDataList={[
createFormData(),
]}
/>,
)
expect(screen.queryByTestId('tips')).not.toBeInTheDocument()
})
it('should render an empty container when there are no visible forms', () => {
render(
<HumanInputFormList
humanInputFormDataList={[]}
/>,
)
expect(screen.queryByTestId('content-wrapper')).not.toBeInTheDocument()
})
})

View File

@ -1,115 +1,252 @@
import type { PanelProps } from '../index'
import { screen } from '@testing-library/react'
import { createNode } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Panel from '../index'
const mockVersionHistoryPanel = vi.hoisted(() => vi.fn())
class MockResizeObserver implements ResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
constructor(_callback: ResizeObserverCallback) {}
type MockNodeData = {
selected?: boolean
title?: string
}
vi.mock('@/next/dynamic', () => ({
default: () => (props: { latestVersionId?: string }) => {
mockVersionHistoryPanel(props)
return <div data-testid="version-history-panel">{props.latestVersionId}</div>
},
type MockNode = {
id: string
type: string
data: MockNodeData
}
type MockPanelStoreState = {
showEnvPanel: boolean
isRestoring: boolean
showWorkflowVersionHistoryPanel: boolean
workflowCanvasWidth: number
previewPanelWidth: number
setPreviewPanelWidth: (value: number) => void
setRightPanelWidth: (value: number) => void
setOtherPanelWidth: (value: number) => void
}
type MockResizeMode = 'borderBox' | 'contentRect' | 'fallback'
let mockResizeModes: MockResizeMode[] = []
let mockResizeObservers: MockResizeObserver[] = []
const createResizeEntry = (mode: MockResizeMode): ResizeObserverEntry => ({
borderBoxSize: mode === 'borderBox'
? [{ inlineSize: 720, blockSize: 0 }] as ResizeObserverSize[]
: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
contentRect: {
width: mode === 'contentRect' ? 530 : 0,
height: 0,
x: 0,
y: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
toJSON: () => ({}),
} as DOMRectReadOnly,
target: document.createElement('div'),
} as unknown as ResizeObserverEntry)
class MockResizeObserver {
callback: ResizeObserverCallback
observe = vi.fn(() => {
if (!mockResizeModes.length)
return
this.callback(
mockResizeModes.map(createResizeEntry),
this as unknown as ResizeObserver,
)
})
disconnect = vi.fn()
unobserve = vi.fn()
constructor(callback: ResizeObserverCallback) {
this.callback = callback
mockResizeObservers.push(this)
}
}
let mockNodes: MockNode[] = []
let mockPanelStoreState: MockPanelStoreState
vi.mock('reactflow', () => ({
useStore: (selector: (state: { getNodes: () => MockNode[] }) => unknown) => selector({
getNodes: () => mockNodes,
}),
useStoreApi: () => ({
getState: () => ({
getNodes: () => mockNodes,
setNodes: vi.fn(),
}),
}),
}))
vi.mock('reactflow', async () => {
const mod = await import('../../__tests__/reactflow-mock-state')
const base = mod.createReactFlowModuleMock()
return {
...base,
useStore: vi.fn(selector => selector({
getNodes: () => mod.rfState.nodes,
})),
}
})
vi.mock('../env-panel', () => ({
default: () => <div data-testid="env-panel" />,
vi.mock('../../store', () => ({
useStore: <T,>(selector: (state: MockPanelStoreState) => T) => selector(mockPanelStoreState),
}))
vi.mock('../../nodes', () => ({
Panel: ({ id }: { id: string }) => <div data-testid="node-panel">{id}</div>,
Panel: ({ id, data }: { id: string, data: MockNodeData }) => (
<div data-testid="node-panel">{`${id}:${data.title || 'untitled'}`}</div>
),
}))
const versionHistoryPanelProps = {
latestVersionId: 'version-1',
restoreVersionUrl: (versionId: string) => `/workflows/${versionId}/restore`,
} satisfies NonNullable<PanelProps['versionHistoryPanelProps']>
vi.mock('@/app/components/workflow/panel/env-panel', () => ({
default: () => <div data-testid="env-panel">env-panel</div>,
}))
vi.mock('@/app/components/workflow/panel/version-history-panel', () => ({
default: ({ latestVersionId }: { latestVersionId?: string }) => (
<div data-testid="version-history-panel">{latestVersionId || 'none'}</div>
),
}))
vi.mock('@/next/dynamic', async () => {
const ReactModule = await import('react')
return {
default: (
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
) => {
const DynamicComponent = (props: Record<string, unknown>) => {
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
ReactModule.useEffect(() => {
let mounted = true
loader().then((mod) => {
if (mounted)
setLoaded(() => mod.default)
})
return () => {
mounted = false
}
}, [])
return Loaded ? <Loaded {...props} /> : null
}
return DynamicComponent
},
}
})
describe('Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
beforeAll(() => {
vi.stubGlobal('ResizeObserver', MockResizeObserver)
})
beforeEach(() => {
vi.clearAllMocks()
mockNodes = []
mockResizeModes = []
mockResizeObservers = []
mockPanelStoreState = {
showEnvPanel: false,
isRestoring: false,
showWorkflowVersionHistoryPanel: false,
workflowCanvasWidth: 0,
previewPanelWidth: 420,
setPreviewPanelWidth: vi.fn(),
setRightPanelWidth: vi.fn(),
setOtherPanelWidth: vi.fn(),
}
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
width: 640,
height: 320,
top: 0,
right: 640,
bottom: 320,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}))
})
afterEach(() => {
vi.restoreAllMocks()
})
afterAll(() => {
vi.unstubAllGlobals()
})
describe('Version History Panel', () => {
it('should render the version history panel when the panel is open and props are provided', () => {
renderWorkflowComponent(
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: true,
},
},
)
it('should render slots, selected node details, and secondary panels while constraining oversized preview widths', async () => {
mockNodes = [{
id: 'node-1',
type: 'custom',
data: {
selected: true,
title: 'Selected Node',
},
}]
mockPanelStoreState = {
...mockPanelStoreState,
showEnvPanel: true,
showWorkflowVersionHistoryPanel: true,
workflowCanvasWidth: 1000,
previewPanelWidth: 520,
}
expect(screen.getByTestId('version-history-panel')).toHaveTextContent('version-1')
expect(mockVersionHistoryPanel).toHaveBeenCalledWith(expect.objectContaining({
latestVersionId: 'version-1',
}))
})
render(
<Panel
components={{
left: <div>left-slot</div>,
right: <div>right-slot</div>,
}}
versionHistoryPanelProps={{
latestVersionId: 'version-1',
restoreVersionUrl: versionId => `/apps/app-1/workflows/${versionId}/restore`,
}}
/>,
)
it('should not render the version history panel when the panel is open but props are missing', () => {
renderWorkflowComponent(
<Panel />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: true,
},
},
)
expect(screen.getByText('left-slot')).toBeInTheDocument()
expect(screen.getByText('right-slot')).toBeInTheDocument()
expect(screen.getByTestId('node-panel')).toHaveTextContent('node-1:Selected Node')
expect(screen.getByTestId('env-panel')).toBeInTheDocument()
expect(await screen.findByTestId('version-history-panel')).toHaveTextContent('version-1')
expect(mockPanelStoreState.setPreviewPanelWidth).toHaveBeenCalledWith(400)
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(640)
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(640)
})
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(mockVersionHistoryPanel).not.toHaveBeenCalled()
})
it('should skip node and auxiliary panels when there is no selected node or open side panel state', () => {
render(
<Panel
components={{
left: <div>left-only</div>,
}}
/>,
)
it('should not render the version history panel when the panel is closed', () => {
rfState.nodes = [
createNode({
id: 'selected-node',
data: {
selected: true,
},
}),
] as typeof rfState.nodes
expect(screen.getByText('left-only')).toBeInTheDocument()
expect(screen.queryByTestId('node-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('env-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(mockPanelStoreState.setPreviewPanelWidth).not.toHaveBeenCalled()
})
renderWorkflowComponent(
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: false,
},
},
)
it('should derive observer widths from border-box, content-rect, and fallback values and disconnect on unmount', () => {
mockResizeModes = ['borderBox', 'contentRect', 'fallback']
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('node-panel')).toHaveTextContent('selected-node')
})
const { unmount } = render(<Panel />)
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(720)
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(530)
expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(640)
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(720)
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(530)
expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(640)
unmount()
expect(mockResizeObservers).toHaveLength(2)
mockResizeObservers.forEach(observer => expect(observer.disconnect).toHaveBeenCalledTimes(1))
})
})

View File

@ -0,0 +1,354 @@
import type { Shape } from '../../store/workflow'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import { toast } from '@/app/components/base/ui/toast'
import { createNodeTracing, createWorkflowRunningData } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { submitHumanInputForm } from '@/service/workflow'
import WorkflowPreview from '../workflow-preview'
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: vi.fn(),
},
}))
vi.mock('@/service/workflow', () => ({
submitHumanInputForm: vi.fn(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
}))
vi.mock('@/app/components/workflow/run/result-panel', () => ({
default: ({ status }: { status?: string }) => <div data-testid="result-panel">{status}</div>,
}))
vi.mock('@/app/components/workflow/run/result-text', () => ({
default: ({
outputs,
isPaused,
isRunning,
onClick,
}: {
outputs?: string
isPaused?: boolean
isRunning?: boolean
onClick?: () => void
}) => (
<div>
<div data-testid="result-text">{JSON.stringify({ outputs, isPaused, isRunning })}</div>
<button type="button" onClick={onClick}>open-detail</button>
</div>
),
}))
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
default: ({ list }: { list: unknown[] }) => <div data-testid="tracing-panel">{list.length}</div>,
}))
vi.mock('@/app/components/workflow/panel/inputs-panel', () => ({
default: ({ onRun }: { onRun: () => void }) => (
<button type="button" onClick={onRun}>
run-inputs
</button>
),
}))
vi.mock('@/app/components/workflow/panel/human-input-form-list', () => ({
default: ({
humanInputFormDataList,
onHumanInputFormSubmit,
}: {
humanInputFormDataList: unknown[]
onHumanInputFormSubmit?: (token: string, formData: Record<string, string>) => Promise<void>
}) => (
<div>
<div data-testid="human-form-list">{humanInputFormDataList.length}</div>
<button type="button" onClick={() => onHumanInputFormSubmit?.('form-token', { answer: 'ok' })}>
submit-human-form
</button>
</div>
),
}))
vi.mock('@/app/components/workflow/panel/human-input-filled-form-list', () => ({
default: ({ humanInputFilledFormDataList }: { humanInputFilledFormDataList: unknown[] }) => (
<div data-testid="filled-form-list">{humanInputFilledFormDataList.length}</div>
),
}))
const mockCopy = vi.mocked(copy)
const mockToastSuccess = vi.mocked(toast.success)
const mockSubmitHumanInputForm = vi.mocked(submitHumanInputForm)
type WorkflowResult = NonNullable<ReturnType<typeof createWorkflowRunningData>['result']>
const createWorkflowResult = (overrides: Partial<WorkflowResult> = {}): WorkflowResult => ({
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
...overrides,
})
const createHumanInputFormData = (
overrides: Partial<HumanInputFormData> = {},
): HumanInputFormData => ({
form_id: 'form-1',
node_id: 'human-node-1',
node_title: 'Need Approval',
form_content: 'Before {{#$output.reason#}} after',
inputs: [],
actions: [],
form_token: 'token-1',
resolved_default_values: {},
display_in_ui: true,
expiration_time: 2_000_000_000,
...overrides,
})
const createHumanInputFilledFormData = (
overrides: Partial<HumanInputFilledFormData> = {},
): HumanInputFilledFormData => ({
node_id: 'node-1',
node_title: 'Need Approval',
rendered_content: 'rendered',
action_id: 'approve',
action_text: 'Approve',
...overrides,
})
describe('WorkflowPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1200,
})
})
it('should keep the input tab active, switch to result after running, and close the preview panel', async () => {
const user = userEvent.setup()
const { container } = renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
showInputsPanel: true,
showDebugAndPreviewPanel: true,
previewPanelWidth: 420,
},
},
)
expect(screen.getByRole('button', { name: 'run-inputs' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'run-inputs' }))
expect(screen.getByTestId('result-text')).toBeInTheDocument()
await user.click(container.querySelector('.flex.items-center.justify-between .cursor-pointer.p-1') as HTMLElement)
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
})
it('should switch to detail when the workflow is listening', () => {
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
isListening: true,
workflowRunningData: createWorkflowRunningData({
result: createWorkflowResult({
status: WorkflowRunningStatus.Running,
}),
}),
},
},
)
expect(screen.getByTestId('result-panel')).toHaveTextContent(WorkflowRunningStatus.Running)
})
it('should switch to detail when a finished run has no outputs or files', () => {
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: {
...createWorkflowRunningData({
result: createWorkflowResult({
status: WorkflowRunningStatus.Succeeded,
files: [],
}),
}),
resultText: '',
} as NonNullable<Shape['workflowRunningData']>,
},
},
)
expect(screen.getByTestId('result-panel')).toHaveTextContent(WorkflowRunningStatus.Succeeded)
})
it('should render paused human input results and submit pending forms', async () => {
const user = userEvent.setup()
const pausedData = createWorkflowRunningData({
result: createWorkflowResult({
status: WorkflowRunningStatus.Paused,
files: [],
}),
humanInputFormDataList: [createHumanInputFormData()],
humanInputFilledFormDataList: [createHumanInputFilledFormData()],
})
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: pausedData,
},
},
)
expect(screen.getByTestId('human-form-list')).toHaveTextContent('1')
expect(screen.getByTestId('filled-form-list')).toHaveTextContent('1')
await user.click(screen.getByRole('button', { name: 'submit-human-form' }))
expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('form-token', { answer: 'ok' })
})
it('should copy successful string output and show a success toast', async () => {
const user = userEvent.setup()
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: {
...createWorkflowRunningData({
result: createWorkflowResult({
status: WorkflowRunningStatus.Succeeded,
files: [],
}),
}),
resultText: 'final answer',
} as NonNullable<Shape['workflowRunningData']>,
},
},
)
await user.click(screen.getByText('runLog.result'))
await user.click(screen.getByRole('button', { name: 'common.operation.copy' }))
expect(mockCopy).toHaveBeenCalledWith('final answer')
expect(mockToastSuccess).toHaveBeenCalledWith('common.actionMsg.copySuccessfully')
})
it('should show a loading state for an empty detail panel', () => {
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
isListening: true,
workflowRunningData: undefined,
},
},
)
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
})
it('should show a loading state for an empty tracing panel', () => {
renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
workflowRunningData: createWorkflowRunningData({
tracing: [],
}),
},
},
)
expect(screen.getByTestId('tracing-panel')).toHaveTextContent('0')
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
})
it('should keep inert tabs disabled without run data and switch among result, detail, and tracing when data exists', async () => {
const user = userEvent.setup()
const { store } = renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
showInputsPanel: true,
workflowRunningData: undefined,
},
},
)
await user.click(screen.getByText('runLog.result'))
await user.click(screen.getByText('runLog.detail'))
await user.click(screen.getByText('runLog.tracing'))
expect(screen.getByRole('button', { name: 'run-inputs' })).toBeInTheDocument()
store.setState({
workflowRunningData: {
...createWorkflowRunningData({
result: createWorkflowResult({
status: WorkflowRunningStatus.Succeeded,
files: [],
}),
tracing: [createNodeTracing()],
}),
resultText: 'ready',
} as NonNullable<Shape['workflowRunningData']>,
})
await user.click(screen.getByText('runLog.result'))
expect(screen.getByTestId('result-text')).toBeInTheDocument()
await user.click(screen.getByText('runLog.detail'))
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
await user.click(screen.getByText('runLog.tracing'))
expect(screen.getByTestId('tracing-panel')).toHaveTextContent('1')
await user.click(screen.getByText('runLog.result'))
await user.click(screen.getByRole('button', { name: 'open-detail' }))
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
})
it('should resize the preview panel within the allowed workflow canvas bounds', async () => {
const { container, store } = renderWorkflowComponent(
<WorkflowPreview />,
{
initialStoreState: {
previewPanelWidth: 450,
workflowCanvasWidth: 1000,
},
},
)
const resizeHandle = container.querySelector('.cursor-col-resize') as HTMLElement
fireEvent.mouseDown(resizeHandle)
fireEvent.mouseMove(window, { clientX: 700 })
fireEvent.mouseMove(window, { clientX: 100 })
fireEvent.mouseUp(window)
await waitFor(() => {
expect(store.getState().previewPanelWidth).toBe(500)
})
})
})

View File

@ -0,0 +1,176 @@
import type { ChatItemInTree } from '@/app/components/base/chat/types'
import type { HistoryWorkflowData } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import ChatRecord from '../index'
import UserInput from '../user-input'
const mockFetchConversationMessages = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
vi.mock('@/service/debug', () => ({
fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args),
}))
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getProcessedFilesFromResponse: (files: Array<{ id: string }>) => files.map(file => ({ ...file, processed: true })),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowRun: () => ({
handleLoadBackupDraft: mockHandleLoadBackupDraft,
}),
}))
vi.mock('@/app/components/base/chat/chat', () => ({
default: ({
chatList,
chatNode,
switchSibling,
}: {
chatList: ChatItemInTree[]
chatNode: React.ReactNode
switchSibling: (messageId: string) => void
}) => (
<div>
<button type="button" onClick={() => switchSibling('msg-2')}>
switch sibling
</button>
<div data-testid="chat-node">{chatNode}</div>
{chatList.map(item => (
<div key={item.id}>{`${item.content}:files-${item.message_files?.length ?? 0}`}</div>
))}
</div>
),
}))
const historyWorkflowData: HistoryWorkflowData = {
id: 'run-1',
status: 'succeeded',
conversation_id: 'conversation-1',
finished_at: 1_700_000_000,
}
describe('ChatRecord integration', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
})
})
it('should render fetched chat history and switch sibling threads', async () => {
const user = userEvent.setup()
mockFetchConversationMessages.mockResolvedValue({
data: [
{
id: 'msg-1',
query: 'Question 1',
answer: 'Answer 1',
metadata: {},
message_files: [
{ id: 'user-file-1', belongs_to: 'user' },
{ id: 'assistant-file-1', belongs_to: 'assistant' },
],
},
{ id: 'msg-2', query: 'Question 2', answer: 'Answer 2', parent_message_id: 'msg-1', metadata: {}, message_files: [] },
{ id: 'msg-3', query: 'Question 3', answer: 'Answer 3', parent_message_id: 'msg-1', metadata: {}, message_files: [] },
],
})
renderWorkflowComponent(<ChatRecord />, {
initialStoreState: {
historyWorkflowData,
},
})
await waitFor(() => {
expect(mockFetchConversationMessages).toHaveBeenCalledWith('app-1', 'conversation-1')
})
expect(screen.getByText('Question 1:files-1')).toBeInTheDocument()
expect(screen.getByText('Answer 1:files-1')).toBeInTheDocument()
expect(screen.getByText('Question 3:files-0')).toBeInTheDocument()
expect(screen.getByText('Answer 3:files-0')).toBeInTheDocument()
expect(screen.queryByText('Question 2:files-0')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'switch sibling' }))
expect(screen.getByText('Question 2:files-0')).toBeInTheDocument()
expect(screen.getByText('Answer 2:files-0')).toBeInTheDocument()
expect(screen.queryByText('Question 3:files-0')).not.toBeInTheDocument()
})
it('should close the record panel and restore the backup draft', async () => {
const user = userEvent.setup()
mockFetchConversationMessages.mockResolvedValue({
data: [
{ id: 'msg-1', query: 'Question 1', answer: 'Answer 1', metadata: {}, message_files: [] },
],
})
const { container, store } = renderWorkflowComponent(<ChatRecord />, {
initialStoreState: {
historyWorkflowData,
},
})
await screen.findByText('Question 1:files-0')
const closeButton = container.querySelector('.h-6.w-6.cursor-pointer') as HTMLElement
await user.click(closeButton)
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
expect(store.getState().historyWorkflowData).toBeUndefined()
})
it('should stop loading even when conversation fetch fails', async () => {
mockFetchConversationMessages.mockRejectedValue(new Error('network error'))
const { container } = renderWorkflowComponent(<ChatRecord />, {
initialStoreState: {
historyWorkflowData,
},
})
await waitFor(() => {
expect(container).toHaveTextContent('TEST CHAT')
})
expect(screen.queryByText('Question 1')).not.toBeInTheDocument()
})
it('should render no user-input block when the variable list is empty', () => {
const { container } = render(<UserInput />)
expect(container.firstChild).toBeNull()
})
it('should render provided user-input variables and toggle the panel body', async () => {
const user = userEvent.setup()
const { container } = render(
<UserInput
variables={[
{ variable: 'query' },
{ variable: 'locale' },
]}
initialExpanded={false}
/>,
)
const header = screen.getByText('WORKFLOW.PANEL.USERINPUTFIELD')
expect(container.querySelectorAll('.mb-2')).toHaveLength(0)
await user.click(header)
expect(container.querySelectorAll('.mb-2')).toHaveLength(2)
await user.click(header)
expect(container.querySelectorAll('.mb-2')).toHaveLength(0)
})
})

View File

@ -5,10 +5,21 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
const UserInput = () => {
type UserInputVariable = {
variable: string
}
type UserInputProps = {
variables?: UserInputVariable[]
initialExpanded?: boolean
}
const UserInput = ({
variables = [],
initialExpanded = true,
}: UserInputProps) => {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(true)
const variables: any = []
const [expanded, setExpanded] = useState(initialExpanded)
if (!variables.length)
return null

View File

@ -0,0 +1,262 @@
import type { ConversationVariable, Node } from '@/app/components/workflow/types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ChatVariablePanel from '../index'
import { ChatVarType } from '../type'
type MockWorkflowStoreState = {
setShowChatVariablePanel: (value: boolean) => void
conversationVariables: ConversationVariable[]
setConversationVariables: (value: ConversationVariable[]) => void
}
type MockFlowStore = {
getNodes: () => Node[]
setNodes: (nodes: Node[]) => void
}
const mockSetShowChatVariablePanel = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockDoSyncWorkflowDraft = vi.fn((_sync: boolean, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
const mockInvalidateConversationVarValues = vi.fn()
const mockFindUsedVarNodes = vi.fn<(selector: string[], nodes: Node[]) => Node[]>()
const mockUpdateNodeVars = vi.fn<(node: Node, current: string[], next: string[]) => Node>()
let mockConversationVariables: ConversationVariable[] = []
let mockFlowNodes: Node[] = []
const mockSetNodes = vi.fn<(nodes: Node[]) => void>()
const createConversationVariable = (
overrides: Partial<ConversationVariable> = {},
): ConversationVariable => ({
id: 'var-1',
name: 'conversation_var',
value_type: ChatVarType.String,
value: '',
description: 'Conversation variable',
...overrides,
})
const createNode = (id: string): Node => ({
id,
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: id,
desc: '',
type: 'llm' as Node['data']['type'],
},
})
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: (): MockFlowStore => ({
getNodes: () => mockFlowNodes,
setNodes: mockSetNodes,
}),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => selector({
setShowChatVariablePanel: mockSetShowChatVariablePanel,
conversationVariables: mockConversationVariables,
setConversationVariables: mockSetConversationVariables,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
vi.mock('../../../hooks/use-inspect-vars-crud', () => ({
default: () => ({
invalidateConversationVarValues: mockInvalidateConversationVarValues,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
findUsedVarNodes: (...args: Parameters<typeof mockFindUsedVarNodes>) => mockFindUsedVarNodes(...args),
updateNodeVars: (...args: Parameters<typeof mockUpdateNodeVars>) => mockUpdateNodeVars(...args),
}))
vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/variable-item', () => ({
default: ({
item,
onEdit,
onDelete,
}: {
item: ConversationVariable
onEdit: (item: ConversationVariable) => void
onDelete: (item: ConversationVariable) => void
}) => (
<div>
<span>{item.name}</span>
<button type="button" onClick={() => onEdit(item)}>{`edit-${item.name}`}</button>
<button type="button" onClick={() => onDelete(item)}>{`delete-${item.name}`}</button>
</div>
),
}))
vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger', () => ({
default: ({
open,
showTip,
chatVar,
onSave,
onClose,
}: {
open: boolean
showTip: boolean
chatVar?: ConversationVariable
onSave: (chatVar: ConversationVariable) => void
onClose: () => void
}) => (
<div data-testid="variable-modal-trigger">
<span>{open ? 'open' : 'closed'}</span>
<span>{showTip ? 'tip-on' : 'tip-off'}</span>
<span>{chatVar?.name || 'new-variable'}</span>
<button
type="button"
onClick={() => onSave({
id: 'var-added',
name: 'fresh_var',
value_type: ChatVarType.String,
value: '',
description: 'Added variable',
})}
>
save-add
</button>
{chatVar && (
<button
type="button"
onClick={() => onSave({
...chatVar,
name: `${chatVar.name}_next`,
})}
>
save-edit
</button>
)}
<button type="button" onClick={onClose}>close-trigger</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm', () => ({
default: ({
isShow,
onConfirm,
onCancel,
}: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
}) => {
if (!isShow)
return null
return (
<div data-testid="remove-effect-var-confirm">
<button type="button" onClick={onConfirm}>confirm-remove</button>
<button type="button" onClick={onCancel}>cancel-remove</button>
</div>
)
},
}))
describe('ChatVariablePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConversationVariables = [createConversationVariable()]
mockFlowNodes = [createNode('node-1'), createNode('node-2')]
mockFindUsedVarNodes.mockReturnValue([])
mockUpdateNodeVars.mockImplementation((node: Node) => node)
})
it('should toggle the tips area and close the panel', async () => {
const user = userEvent.setup()
const { container } = render(<ChatVariablePanel />)
expect(screen.getByText('workflow.chatVariable.panelDescription')).toBeInTheDocument()
const toggleTipButton = screen.getAllByRole('button')[0]!
await user.click(toggleTipButton)
expect(screen.queryByText('workflow.chatVariable.panelDescription')).not.toBeInTheDocument()
const closeButton = container.querySelector('.flex.h-6.w-6.cursor-pointer.items-center.justify-center') as HTMLElement
await user.click(closeButton)
expect(mockSetShowChatVariablePanel).toHaveBeenCalledWith(false)
})
it('should prepend newly added variables and sync the workflow draft', async () => {
const user = userEvent.setup()
render(<ChatVariablePanel />)
await user.click(screen.getByRole('button', { name: 'save-add' }))
await waitFor(() => {
expect(mockSetConversationVariables).toHaveBeenCalledWith([
expect.objectContaining({ id: 'var-added', name: 'fresh_var' }),
createConversationVariable(),
])
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
expect(mockInvalidateConversationVarValues).toHaveBeenCalledTimes(1)
})
it('should rename existing variables and update affected node references', async () => {
const user = userEvent.setup()
const effectedNode = createNode('node-1')
const updatedNode = createNode('node-1-updated')
mockFindUsedVarNodes.mockReturnValue([effectedNode])
mockUpdateNodeVars.mockReturnValue(updatedNode)
render(<ChatVariablePanel />)
await user.click(screen.getByRole('button', { name: 'edit-conversation_var' }))
await user.click(screen.getByRole('button', { name: 'save-edit' }))
expect(mockSetConversationVariables).toHaveBeenCalledWith([
expect.objectContaining({ id: 'var-1', name: 'conversation_var_next' }),
])
expect(mockUpdateNodeVars).toHaveBeenCalledWith(
effectedNode,
['conversation', 'conversation_var'],
['conversation', 'conversation_var_next'],
)
expect(mockSetNodes).toHaveBeenCalledWith([updatedNode, createNode('node-2')])
})
it('should require confirmation before deleting variables referenced by workflow nodes', async () => {
const user = userEvent.setup()
const effectedNode = createNode('node-1')
const prunedNode = createNode('node-1-pruned')
mockFindUsedVarNodes.mockReturnValue([effectedNode])
mockUpdateNodeVars.mockReturnValue(prunedNode)
render(<ChatVariablePanel />)
await user.click(screen.getByRole('button', { name: 'delete-conversation_var' }))
expect(screen.getByTestId('remove-effect-var-confirm')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'confirm-remove' }))
expect(mockUpdateNodeVars).toHaveBeenCalledWith(
effectedNode,
['conversation', 'conversation_var'],
[],
)
expect(mockSetNodes).toHaveBeenCalledWith([prunedNode, createNode('node-2')])
expect(mockSetConversationVariables).toHaveBeenCalledWith([])
})
})

View File

@ -0,0 +1,282 @@
/* eslint-disable ts/no-explicit-any */
import type { ConversationVariable } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import ArrayBoolList from '../array-bool-list'
import ArrayValueList from '../array-value-list'
import VariableItem from '../variable-item'
import VariableModalTrigger from '../variable-modal-trigger'
import VariableTypeSelector from '../variable-type-select'
vi.mock('../variable-modal', () => ({
default: ({ chatVar, onSave, onClose }: any) => (
<div>
{chatVar?.name && <div>{chatVar.name}</div>}
<button type="button" onClick={() => onSave({ id: 'saved' })}>save-modal</button>
<button type="button" onClick={onClose}>close-modal</button>
</div>
),
}))
const createVariable = (overrides: Partial<ConversationVariable> = {}): ConversationVariable => ({
id: 'var-1',
name: 'conversation_var',
description: 'Conversation scoped variable',
value_type: ChatVarType.String,
value: '',
...overrides,
})
describe('chat-variable-panel components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The panel leaf components should support editing, selecting types, and opening the add-variable modal.
describe('Leaf interactions', () => {
it('should update string array items, add rows, and remove rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ArrayValueList
isString
list={['alpha', 'beta']}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('alpha'), { target: { value: 'updated' } })
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
await user.click(screen.getAllByRole('button')[0]!)
expect(onChange).toHaveBeenCalledTimes(3)
})
it('should coerce number array items and append undefined rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ArrayValueList
isString={false}
list={[1]}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '7' } })
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
expect(onChange).toHaveBeenNthCalledWith(1, [7])
expect(onChange).toHaveBeenNthCalledWith(2, [1, undefined])
})
it('should call edit and delete handlers from the variable item actions', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
const onDelete = vi.fn()
const { container } = render(
<VariableItem
item={createVariable()}
onEdit={onEdit}
onDelete={onDelete}
/>,
)
const card = container.firstElementChild as HTMLDivElement
const actions = container.querySelectorAll('.cursor-pointer')
fireEvent.mouseOver(actions[1] as Element)
expect(card.className).toContain('border-state-destructive-border')
fireEvent.mouseOut(actions[1] as Element)
expect(card.className).not.toContain('border-state-destructive-border')
const icons = container.querySelectorAll('svg')
await user.click(icons[1] as SVGElement)
await user.click(icons[2] as SVGElement)
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 'var-1' }))
expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'var-1' }))
})
it('should toggle the type selector and select a new value', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<VariableTypeSelector
value="string"
list={['string', 'number', 'boolean']}
onSelect={onSelect}
/>,
)
await user.click(screen.getByText('string'))
await user.click(screen.getByText('number'))
expect(onSelect).toHaveBeenCalledWith('number')
})
it('should dismiss the type selector through the real portal close flow', async () => {
const user = userEvent.setup()
render(
<VariableTypeSelector
value="string"
list={['string', 'number']}
onSelect={vi.fn()}
/>,
)
await user.click(screen.getByText('string'))
expect(screen.getByText('number')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByText('number')).not.toBeInTheDocument()
})
})
it('should open the in-cell selector from its trigger and keep the popup class', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<VariableTypeSelector
inCell
value="string"
list={['string', 'number']}
popupClassName="custom-popup"
onSelect={onSelect}
/>,
)
await user.click(screen.getAllByText('string')[0]!)
expect(screen.getByText('number').closest('.custom-popup')).not.toBeNull()
await user.click(screen.getAllByText('string')[1]!)
expect(onSelect).toHaveBeenCalledWith('string')
})
it('should update, add, and remove boolean array values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { container } = render(
<ArrayBoolList
list={[true]}
onChange={onChange}
/>,
)
await user.click(screen.getByText('False'))
expect(onChange).toHaveBeenNthCalledWith(1, [false])
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
expect(onChange).toHaveBeenNthCalledWith(2, [true, false])
const buttons = container.querySelectorAll('button')
await user.click(buttons[0] as HTMLButtonElement)
expect(onChange).toHaveBeenNthCalledWith(3, [])
})
it('should toggle the modal trigger without closing when it starts closed', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
const onClose = vi.fn()
render(
<VariableModalTrigger
open={false}
setOpen={setOpen}
showTip
onClose={onClose}
onSave={vi.fn()}
/>,
)
expect(screen.queryByText('save-modal')).not.toBeInTheDocument()
await user.click(screen.getByText('workflow.chatVariable.button'))
expect(setOpen).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
})
it('should open the modal trigger and close after saving', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
const onClose = vi.fn()
const onSave = vi.fn()
render(
<VariableModalTrigger
open
setOpen={setOpen}
showTip={false}
chatVar={createVariable()}
onClose={onClose}
onSave={onSave}
/>,
)
expect(screen.getByText('conversation_var')).toBeInTheDocument()
await user.click(screen.getByText('save-modal'))
await user.click(screen.getByText('close-modal'))
expect(onSave).toHaveBeenCalledWith({ id: 'saved' })
expect(onClose).toHaveBeenCalled()
expect(setOpen).toHaveBeenCalledWith(false)
})
it('should close the modal trigger when clicking the trigger while already open', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
const onClose = vi.fn()
render(
<VariableModalTrigger
open
setOpen={setOpen}
showTip={false}
chatVar={createVariable()}
onClose={onClose}
onSave={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'workflow.chatVariable.button' }))
expect(onClose).toHaveBeenCalledTimes(1)
expect(setOpen).toHaveBeenCalled()
})
it('should close the modal trigger when the portal dismisses', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const TriggerHarness = () => {
const [open, setOpen] = React.useState(true)
return (
<VariableModalTrigger
open={open}
setOpen={setOpen}
showTip={false}
chatVar={createVariable()}
onClose={onClose}
onSave={vi.fn()}
/>
)
}
render(<TriggerHarness />)
expect(screen.getByText('save-modal')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByText('save-modal')).not.toBeInTheDocument()
})
expect(onClose).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,610 @@
import type { ChatWrapperRefType } from '../index'
import type { ConversationVariable } from '@/app/components/workflow/types'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import { useStore as useAppStore } from '@/app/components/app/store'
import { createStartNode } from '@/app/components/workflow/__tests__/fixtures'
import {
renderWorkflowComponent,
renderWorkflowFlowComponent,
} from '@/app/components/workflow/__tests__/workflow-test-env'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import { InputVarType } from '@/app/components/workflow/types'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { fetchSuggestedQuestions, stopChatMessageResponding } from '@/service/debug'
import { fetchCurrentValueOfConversationVariable } from '@/service/workflow'
import ChatWrapper from '../chat-wrapper'
import ConversationVariableModal from '../conversation-variable-modal'
import UserInput from '../user-input'
const mockUseChat = vi.fn()
const mockChatRender = vi.fn()
const mockUseSubscription = vi.fn()
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
vi.mock('@/service/debug', () => ({
fetchSuggestedQuestions: vi.fn(),
stopChatMessageResponding: vi.fn(),
}))
vi.mock('@/service/workflow', () => ({
fetchCurrentValueOfConversationVariable: vi.fn(),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: (timestamp: number) => `formatted-${timestamp}`,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value }: { value?: string }) => <pre data-testid="conversation-code-editor">{value}</pre>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/form-item', () => ({
default: ({
payload,
value,
onChange,
}: {
payload: { label?: string, variable: string }
value?: string
onChange: (value: string) => void
}) => (
<input
aria-label={payload.label || payload.variable}
value={value ?? ''}
onChange={e => onChange(e.target.value)}
/>
),
}))
vi.mock('@/app/components/base/chat/chat', () => ({
default: ({
chatNode,
inputDisabled,
onSend,
onRegenerate,
switchSibling,
onHumanInputFormSubmit,
onFeatureBarClick,
}: {
chatNode: React.ReactNode
inputDisabled?: boolean
onSend?: (message: string, files: unknown[]) => void
onRegenerate?: (chatItem: { id: string, parentMessageId?: string, content?: string, message_files?: unknown[] }) => void
switchSibling?: (siblingMessageId: string) => void
onHumanInputFormSubmit?: (formToken: string, formData: Record<string, string>) => Promise<void>
onFeatureBarClick?: (state: boolean) => void
}) => {
mockChatRender({
inputDisabled,
hasChatNode: !!chatNode,
})
return (
<div data-testid="chat-shell">
<div data-testid="chat-input-disabled">{`${inputDisabled}`}</div>
<button type="button" onClick={() => onSend?.('hello', [])}>send-chat</button>
<button
type="button"
onClick={() => onRegenerate?.({
id: 'answer-2',
parentMessageId: 'question-1',
content: 'latest answer',
message_files: [],
})}
>
regenerate-chat
</button>
<button type="button" onClick={() => switchSibling?.('sibling-2')}>switch-sibling</button>
<button type="button" onClick={() => onHumanInputFormSubmit?.('token-1', { answer: 'ok' })}>submit-human-input</button>
<button type="button" onClick={() => onFeatureBarClick?.(true)}>open-feature-panel</button>
{chatNode}
</div>
)
},
}))
vi.mock('../hooks', () => ({
useChat: (...args: unknown[]) => mockUseChat(...args),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: <T,>(selector: (state: {
features: {
opening?: { enabled?: boolean, opening_statement?: string, suggested_questions?: string[] }
suggested: boolean
text2speech: boolean
speech2text: boolean
citation: boolean
moderation: boolean
file: { enabled: boolean }
}
}) => T) => selector({
features: {
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
suggested: false,
text2speech: false,
speech2text: false,
citation: false,
moderation: false,
file: { enabled: false },
},
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: mockUseSubscription,
},
}),
}))
const mockFetchCurrentValueOfConversationVariable = vi.mocked(fetchCurrentValueOfConversationVariable)
const mockCopy = vi.mocked(copy)
const mockFetchSuggestedQuestions = vi.mocked(fetchSuggestedQuestions)
const mockStopChatMessageResponding = vi.mocked(stopChatMessageResponding)
const createConversationVariable = (
overrides: Partial<ConversationVariable> = {},
): ConversationVariable => ({
id: 'var-1',
name: 'session_state',
description: 'Session state',
value_type: ChatVarType.Object,
value: '{"draft":true}',
...overrides,
})
const createChatState = (overrides: Record<string, unknown> = {}) => ({
conversationId: 'conversation-1',
chatList: [],
handleStop: vi.fn(),
isResponding: false,
suggestedQuestions: [],
handleSend: vi.fn(),
handleRestart: vi.fn(),
handleSwitchSibling: vi.fn(),
handleSubmitHumanInputForm: vi.fn(),
getHumanInputNodeData: vi.fn(),
...overrides,
})
const createConversationVariableResponse = (
data: Array<Awaited<ReturnType<typeof fetchCurrentValueOfConversationVariable>>['data'][number]> = [],
): Awaited<ReturnType<typeof fetchCurrentValueOfConversationVariable>> => ({
data,
has_more: false,
limit: 20,
total: data.length,
page: 1,
})
const createChatWrapperRef = () => ({ current: null }) as unknown as React.RefObject<ChatWrapperRefType>
describe('debug-and-preview components', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
appDetail: {
id: 'app-1',
site: {
access_token: 'site-token',
app_base_url: 'https://example.com',
},
} as ReturnType<typeof useAppStore.getState>['appDetail'],
})
mockUseChat.mockReturnValue(createChatState())
mockFetchCurrentValueOfConversationVariable.mockResolvedValue(createConversationVariableResponse())
})
afterEach(() => {
vi.useRealTimers()
})
describe('ConversationVariableModal', () => {
it('should load latest values, switch variable tabs, and close the modal', async () => {
const user = userEvent.setup()
const onHide = vi.fn()
mockFetchCurrentValueOfConversationVariable.mockResolvedValue(createConversationVariableResponse([
{
...createConversationVariable({
id: 'var-1',
value: '{"latest":1}',
}),
updated_at: 100,
created_at: 50,
},
{
...createConversationVariable({
id: 'var-2',
name: 'summary',
value_type: ChatVarType.String,
value: 'latest text',
}),
updated_at: 200,
created_at: 150,
},
]))
renderWorkflowComponent(
<ConversationVariableModal
conversationID="conversation-1"
onHide={onHide}
/>,
{
initialStoreState: {
appId: 'app-1',
conversationVariables: [
createConversationVariable(),
createConversationVariable({
id: 'var-2',
name: 'summary',
value_type: ChatVarType.String,
value: 'plain text',
}),
],
},
},
)
await waitFor(() => {
expect(mockFetchCurrentValueOfConversationVariable).toHaveBeenCalledWith({
url: '/apps/app-1/conversation-variables',
params: { conversation_id: 'conversation-1' },
})
})
expect(screen.getAllByText('session_state')).toHaveLength(2)
expect(screen.getByText(content => content.includes('formatted-100'))).toBeInTheDocument()
expect(screen.getByTestId('conversation-code-editor')).toHaveTextContent('{"latest":1}')
const closeTrigger = document.querySelector('.absolute.right-4.top-4.cursor-pointer') as HTMLElement
await user.click(screen.getByText('summary'))
expect(screen.getByText('latest text')).toBeInTheDocument()
await user.click(closeTrigger)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should copy the current variable value and reset the copied state after the timeout', async () => {
vi.useFakeTimers()
renderWorkflowComponent(
<ConversationVariableModal
conversationID="conversation-1"
onHide={vi.fn()}
/>,
{
initialStoreState: {
appId: 'app-1',
conversationVariables: [
createConversationVariable(),
],
},
},
)
const copyTrigger = document.querySelector('.flex.items-center.p-1 svg.cursor-pointer') as HTMLElement
act(() => {
fireEvent.click(copyTrigger)
})
expect(mockCopy).toHaveBeenCalledWith('{"draft":true}')
act(() => {
vi.advanceTimersByTime(2000)
})
})
})
describe('UserInput', () => {
it('should hide secret fields outside the expanded panel and persist edits into workflow state', async () => {
const user = userEvent.setup()
const { store } = renderWorkflowFlowComponent(
<UserInput />,
{
nodes: [
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
},
{
type: InputVarType.textInput,
variable: 'internal_note',
label: 'Internal Note',
hide: true,
},
],
},
}),
],
edges: [],
initialStoreState: {
inputs: {
question: 'draft',
},
showDebugAndPreviewPanel: false,
},
},
)
expect(screen.getByLabelText('Question')).toBeInTheDocument()
expect(screen.queryByLabelText('Internal Note')).not.toBeInTheDocument()
await user.clear(screen.getByLabelText('Question'))
await user.type(screen.getByLabelText('Question'), 'updated draft')
expect(store.getState().inputs).toEqual({
question: 'updated draft',
})
})
it('should reveal hidden fields when the debug-and-preview panel is expanded', () => {
renderWorkflowFlowComponent(
<UserInput />,
{
nodes: [
createStartNode({
data: {
variables: [{
type: InputVarType.textInput,
variable: 'internal_note',
label: 'Internal Note',
hide: true,
}],
},
}),
],
edges: [],
initialStoreState: {
inputs: {},
showDebugAndPreviewPanel: true,
},
},
)
expect(screen.getByLabelText('Internal Note')).toBeInTheDocument()
})
})
describe('ChatWrapper', () => {
it('should seed start defaults into workflow inputs and expose restart through the ref handle', async () => {
const chatState = createChatState()
mockUseChat.mockReturnValue(chatState)
const chatRef = createChatWrapperRef()
const { store } = renderWorkflowFlowComponent(
<ChatWrapper
ref={chatRef}
showConversationVariableModal={false}
onConversationModalHide={vi.fn()}
showInputsFieldsPanel
onHide={vi.fn()}
/>,
{
nodes: [
createStartNode({
data: {
variables: [{
type: InputVarType.textInput,
variable: 'name',
label: 'Name',
default: 'Ada',
}],
},
}),
],
edges: [],
initialStoreState: {
inputs: {
custom: 'value',
},
},
},
)
await waitFor(() => {
expect(store.getState().inputs).toEqual({
custom: 'value',
name: 'Ada',
})
})
expect(screen.getByText('workflow.common.previewPlaceholder')).toBeInTheDocument()
act(() => {
chatRef.current?.handleRestart()
})
expect(chatState.handleRestart).toHaveBeenCalledTimes(1)
expect(store.getState().inputs).toEqual({
name: 'Ada',
})
})
it('should hide the side panel while responding and render the conversation modal when requested', async () => {
const onHide = vi.fn()
mockUseChat.mockReturnValue(createChatState({
isResponding: true,
}))
mockFetchCurrentValueOfConversationVariable.mockResolvedValue(createConversationVariableResponse([
{
...createConversationVariable({
id: 'var-1',
value: '{"latest":1}',
}),
updated_at: 100,
created_at: 50,
},
]))
renderWorkflowFlowComponent(
<ChatWrapper
ref={createChatWrapperRef()}
showConversationVariableModal
onConversationModalHide={vi.fn()}
showInputsFieldsPanel={false}
onHide={onHide}
/>,
{
nodes: [
createStartNode({
data: {
variables: [],
},
}),
],
edges: [],
initialStoreState: {
appId: 'app-1',
conversationVariables: [
createConversationVariable(),
],
},
},
)
await waitFor(() => {
expect(onHide).toHaveBeenCalledTimes(1)
})
expect(screen.getAllByText('session_state')).toHaveLength(2)
})
it('should forward chat actions, stop subscriptions, and expose paused input state', async () => {
const user = userEvent.setup()
const handleSend = vi.fn()
const handleSwitchSibling = vi.fn()
const handleSubmitHumanInputForm = vi.fn().mockResolvedValue(undefined)
const handleStop = vi.fn()
mockUseChat.mockReturnValue(createChatState({
chatList: [
{
id: 'answer-1',
isAnswer: true,
content: 'first answer',
},
{
id: 'question-1',
isAnswer: false,
content: 'first question',
parentMessageId: 'answer-1',
message_files: [],
},
{
id: 'answer-2',
isAnswer: true,
parentMessageId: 'question-1',
content: 'latest answer',
workflowProcess: {
status: 'paused',
},
},
],
handleSend,
handleSwitchSibling,
handleSubmitHumanInputForm,
handleStop,
}))
const { store } = renderWorkflowFlowComponent(
<ChatWrapper
ref={createChatWrapperRef()}
showConversationVariableModal={false}
onConversationModalHide={vi.fn()}
showInputsFieldsPanel={false}
onHide={vi.fn()}
/>,
{
nodes: [
createStartNode({
data: {
variables: [{
type: InputVarType.textInput,
variable: 'name',
label: 'Name',
default: 'Ada',
}],
},
}),
],
edges: [],
initialStoreState: {
inputs: {
existing: 'value',
},
},
},
)
await waitFor(() => {
expect(store.getState().inputs).toEqual({
existing: 'value',
name: 'Ada',
})
})
expect(screen.getByTestId('chat-input-disabled')).toHaveTextContent('true')
await user.click(screen.getByRole('button', { name: 'send-chat' }))
expect(handleSend).toHaveBeenCalledWith(expect.objectContaining({
query: 'hello',
conversation_id: 'conversation-1',
inputs: {
existing: 'value',
name: 'Ada',
},
parent_message_id: 'answer-2',
}), expect.objectContaining({
onGetSuggestedQuestions: expect.any(Function),
}))
const sendCallbacks = handleSend.mock.calls[0]?.[1] as {
onGetSuggestedQuestions: (messageId: string, getAbortController: () => AbortController) => void
}
sendCallbacks.onGetSuggestedQuestions('message-1', () => new AbortController())
expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith('app-1', 'message-1', expect.any(Function))
await user.click(screen.getByRole('button', { name: 'regenerate-chat' }))
expect(handleSend).toHaveBeenNthCalledWith(2, expect.objectContaining({
query: 'first question',
parent_message_id: 'answer-1',
}), expect.any(Object))
await user.click(screen.getByRole('button', { name: 'switch-sibling' }))
expect(handleSwitchSibling).toHaveBeenCalledWith('sibling-2', expect.objectContaining({
onGetSuggestedQuestions: expect.any(Function),
}))
const switchCallbacks = handleSwitchSibling.mock.calls[0]?.[1] as {
onGetSuggestedQuestions: (messageId: string, getAbortController: () => AbortController) => void
}
switchCallbacks.onGetSuggestedQuestions('message-2', () => new AbortController())
expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith('app-1', 'message-2', expect.any(Function))
await user.click(screen.getByRole('button', { name: 'submit-human-input' }))
await waitFor(() => {
expect(handleSubmitHumanInputForm).toHaveBeenCalledWith('token-1', { answer: 'ok' })
})
const stopResponding = mockUseChat.mock.calls[0]?.[3] as (taskId: string) => void
stopResponding('task-1')
expect(mockStopChatMessageResponding).toHaveBeenCalledWith('app-1', 'task-1')
const subscription = mockUseSubscription.mock.calls[0]?.[0] as (payload: { type: string }) => void
act(() => {
subscription({ type: EVENT_WORKFLOW_STOP })
})
expect(handleStop).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,267 @@
import type { ReactElement } from 'react'
import type { IToastProps } from '@/app/components/base/toast/context'
import type { Shape } from '@/app/components/workflow/store/workflow'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { ToastContext } from '@/app/components/base/toast/context'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import EnvItem from '../env-item'
import VariableModal from '../variable-modal'
import VariableTrigger from '../variable-trigger'
vi.mock('uuid', () => ({
v4: () => 'env-created',
}))
const createEnv = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({
id: 'env-1',
name: 'api_key',
value: '[__HIDDEN__]',
value_type: 'secret',
description: 'secret description',
...overrides,
})
const renderWithProviders = (
ui: ReactElement,
options: {
storeState?: Partial<Shape>
notify?: (props: IToastProps) => void
} = {},
) => {
const store = createWorkflowStore({})
const notify = options.notify ?? vi.fn<(props: IToastProps) => void>()
if (options.storeState)
store.setState(options.storeState)
const result = render(
<ToastContext.Provider value={{ notify, close: vi.fn() }}>
<WorkflowContext.Provider value={store}>
{ui}
</WorkflowContext.Provider>
</ToastContext.Provider>,
)
return {
...result,
store,
notify,
}
}
describe('EnvPanel integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render secret env items and trigger edit and delete actions', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
const onDelete = vi.fn()
const env = createEnv()
const { container } = renderWithProviders(
<EnvItem env={env} onEdit={onEdit} onDelete={onDelete} />,
{
storeState: {
envSecrets: {
[env.id]: 'masked-value',
},
},
},
)
expect(screen.getByText('api_key')).toBeInTheDocument()
expect(screen.getByText('Secret')).toBeInTheDocument()
expect(screen.getByText('masked-value')).toBeInTheDocument()
expect(screen.getByText('secret description')).toBeInTheDocument()
const actionWrappers = container.querySelectorAll('.cursor-pointer')
const editIcon = actionWrappers[0]?.querySelector('svg')
const deleteWrapper = actionWrappers[1] as HTMLElement
const deleteIcon = deleteWrapper.querySelector('svg')
fireEvent.mouseOver(deleteWrapper)
expect(container.firstElementChild).toHaveClass('border-state-destructive-border')
await user.click(editIcon as SVGElement)
await user.click(deleteIcon as SVGElement)
expect(onEdit).toHaveBeenCalledWith(env)
expect(onDelete).toHaveBeenCalledWith(env)
})
it('should render non-secret env values and clear destructive styling on mouse out', () => {
const env = createEnv({
id: 'env-plain',
name: 'public_value',
value: 'plain-text',
value_type: 'string',
description: '',
})
const { container } = renderWithProviders(
<EnvItem env={env} onEdit={vi.fn()} onDelete={vi.fn()} />,
)
expect(screen.getByText('public_value')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('plain-text')).toBeInTheDocument()
expect(screen.queryByText('secret description')).not.toBeInTheDocument()
const deleteWrapper = container.querySelectorAll('.cursor-pointer')[1] as HTMLElement
fireEvent.mouseOver(deleteWrapper)
expect(container.firstElementChild).toHaveClass('border-state-destructive-border')
fireEvent.mouseOut(deleteWrapper)
expect(container.firstElementChild).not.toHaveClass('border-state-destructive-border')
})
it('should create a secret environment variable and normalize spaces in its name', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const onClose = vi.fn()
renderWithProviders(
<VariableModal onClose={onClose} onSave={onSave} />,
{
storeState: {
environmentVariables: [],
},
},
)
await user.click(screen.getByText('Secret'))
await user.type(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder'), 'my secret')
await user.type(screen.getByPlaceholderText('workflow.env.modal.valuePlaceholder'), 'top-secret')
await user.type(screen.getByPlaceholderText('workflow.env.modal.descriptionPlaceholder'), 'runtime only')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder')).toHaveValue('my_secret')
expect(onSave).toHaveBeenCalledWith({
id: 'env-created',
name: 'my_secret',
value: 'top-secret',
value_type: 'secret',
description: 'runtime only',
})
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should reject invalid and duplicate variable names', async () => {
const user = userEvent.setup()
const notify = vi.fn()
renderWithProviders(
<VariableModal onClose={vi.fn()} onSave={vi.fn()} />,
{
storeState: {
environmentVariables: [createEnv({ id: 'env-existing', name: 'duplicated', value_type: 'string', value: '1' })],
},
notify,
},
)
fireEvent.change(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder'), {
target: { value: '1bad' },
})
expect(notify).toHaveBeenCalled()
notify.mockClear()
await user.clear(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder'))
await user.type(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder'), 'duplicated')
await user.type(screen.getByPlaceholderText('workflow.env.modal.valuePlaceholder'), '42')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'name is existed',
})
})
it('should load existing secret values and convert them to numbers when editing', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
renderWithProviders(
<VariableModal
env={createEnv({
id: 'env-2',
name: 'counter',
value: '[__HIDDEN__]',
description: 'editable',
})}
onClose={vi.fn()}
onSave={onSave}
/>,
{
storeState: {
environmentVariables: [createEnv({ id: 'env-2', name: 'counter' })],
envSecrets: { 'env-2': '123' },
},
},
)
expect(screen.getByDisplayValue('counter')).toBeInTheDocument()
expect(screen.getByDisplayValue('123')).toBeInTheDocument()
await user.click(screen.getByText('Number'))
const valueInput = screen.getByPlaceholderText('workflow.env.modal.valuePlaceholder')
await user.clear(valueInput)
await user.type(valueInput, '9')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith({
id: 'env-2',
name: 'counter',
value: 9,
value_type: 'number',
description: 'editable',
})
})
it('should open and close the variable trigger modal with the real portal flow', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const TriggerHarness = () => {
const [open, setOpen] = React.useState(false)
return (
<VariableTrigger
open={open}
setOpen={setOpen}
onClose={onClose}
onSave={vi.fn()}
/>
)
}
renderWithProviders(<TriggerHarness />)
const trigger = screen.getByRole('button', { name: 'workflow.env.envPanelButton' })
await user.click(trigger)
expect(screen.getByText('workflow.env.modal.title')).toBeInTheDocument()
await user.click(trigger)
expect(onClose).toHaveBeenCalledTimes(1)
expect(screen.queryByText('workflow.env.modal.title')).not.toBeInTheDocument()
await user.click(trigger)
expect(screen.getByText('workflow.env.modal.title')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalledTimes(2)
expect(screen.queryByText('workflow.env.modal.title')).not.toBeInTheDocument()
await user.click(trigger)
const closeIcon = document.querySelector('.h-6.w-6.cursor-pointer') as HTMLElement
await user.click(closeIcon)
expect(onClose).toHaveBeenCalledTimes(3)
expect(screen.queryByText('workflow.env.modal.title')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Panel from '../index'
let mockIsChatMode = true
let mockIsWorkflowPage = false
const mockSetShowGlobalVariablePanel = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { setShowGlobalVariablePanel: (visible: boolean) => void }) => unknown) => selector({
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
}),
}))
vi.mock('../../../constants', () => ({
isInWorkflowPage: () => mockIsWorkflowPage,
}))
vi.mock('../../../hooks', () => ({
useIsChatMode: () => mockIsChatMode,
}))
describe('global-variable-panel path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsChatMode = true
mockIsWorkflowPage = false
})
it('should render chat global variables and close the panel', async () => {
const user = userEvent.setup()
const { container } = render(<Panel />)
expect(screen.getByText('workflow.globalVar.title')).toBeInTheDocument()
expect(screen.getByText((_, node) => node?.textContent === 'sys.conversation_id')).toBeInTheDocument()
expect(screen.getByText((_, node) => node?.textContent === 'sys.dialog_count')).toBeInTheDocument()
expect(screen.queryByText('sys.timestamp')).not.toBeInTheDocument()
await user.click(container.querySelector('.cursor-pointer') as HTMLElement)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
})
it('should render workflow trigger variables for non-chat workflow pages', () => {
mockIsChatMode = false
mockIsWorkflowPage = true
render(<Panel />)
expect(screen.queryByText('sys.conversation_id')).not.toBeInTheDocument()
expect(screen.queryByText('sys.dialog_count')).not.toBeInTheDocument()
expect(screen.getByText((_, node) => node?.textContent === 'sys.timestamp')).toBeInTheDocument()
expect(screen.getByText('workflow.globalVar.fieldsDescription.triggerTimestamp')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,68 @@
import type { Dependency } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PluginDependency from '../index'
import { useStore } from '../store'
vi.mock('@/app/components/plugins/install-plugin/install-bundle', () => ({
__esModule: true,
default: ({
fromDSLPayload,
onClose,
}: {
fromDSLPayload: Dependency[]
onClose: () => void
}) => (
<div>
<div>{`bundle-size:${fromDSLPayload.length}`}</div>
<button type="button" onClick={onClose}>close-bundle</button>
</div>
),
}))
const createDependency = (): Dependency => ({
type: 'marketplace',
value: {
organization: 'langgenius',
plugin: 'sample-plugin',
version: '1.0.0',
plugin_unique_identifier: 'langgenius/sample-plugin:1.0.0',
},
})
describe('plugin-dependency', () => {
beforeEach(() => {
vi.clearAllMocks()
useStore.setState({
dependencies: [],
})
})
it('should render nothing when there are no dependencies to install', () => {
render(<PluginDependency />)
expect(screen.queryByText(/bundle-size/i)).not.toBeInTheDocument()
})
it('should render the install bundle and clear dependencies when closed', async () => {
const user = userEvent.setup()
useStore.setState({
dependencies: [createDependency()],
})
render(<PluginDependency />)
expect(screen.getByText('bundle-size:1')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'close-bundle' }))
expect(useStore.getState().dependencies).toEqual([])
})
it('should update dependencies through the store setter', () => {
const dependency = createDependency()
useStore.getState().setDependencies([dependency])
expect(useStore.getState().dependencies).toEqual([dependency])
})
})

View File

@ -0,0 +1,116 @@
import type { NodeTracing } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import LoopResultPanel from '../loop-result-panel'
const mockTracingPanel = vi.fn()
vi.mock('../tracing-panel', () => ({
default: ({
list,
className,
}: {
list: NodeTracing[]
className?: string
}) => {
mockTracingPanel({ list, className })
return <div data-testid="tracing-panel">{list.length}</div>
},
}))
const createNodeTracing = (id: string): NodeTracing => ({
id,
index: 0,
predecessor_node_id: '',
node_id: `node-${id}`,
node_type: BlockEnum.Code,
title: `Node ${id}`,
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: 'succeeded',
error: '',
elapsed_time: 0,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'Tester',
email: 'tester@example.com',
},
finished_at: 0,
execution_metadata: undefined,
})
describe('LoopResultPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should show loop rows, expand tracing details, and handle back and close actions', () => {
const onHide = vi.fn()
const onBack = vi.fn()
const { container } = render(
<LoopResultPanel
list={[
[createNodeTracing('1')],
[createNodeTracing('2'), createNodeTracing('3')],
]}
onHide={onHide}
onBack={onBack}
noWrap
/>,
)
expect(screen.getByText('workflow.singleRun.testRunLoop')).toBeInTheDocument()
const contentPanels = container.querySelectorAll('.transition-all.duration-200')
expect(contentPanels[0]).toHaveClass('max-h-0')
fireEvent.click(screen.getByText('workflow.singleRun.loop 1'))
expect(contentPanels[0]).not.toHaveClass('max-h-0')
expect(screen.getAllByTestId('tracing-panel')[0]).toHaveTextContent('1')
expect(mockTracingPanel).toHaveBeenCalledWith({
list: [expect.objectContaining({ id: '1' })],
className: 'bg-background-section-burn',
})
fireEvent.click(screen.getByText('workflow.singleRun.back'))
const closeTrigger = container.querySelector('.ml-2.shrink-0.cursor-pointer.p-1')
if (!closeTrigger)
throw new Error('Expected close trigger to be rendered')
fireEvent.click(closeTrigger)
expect(onBack).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should stop click propagation when rendered inside the overlay wrapper', () => {
const parentClick = vi.fn()
const { container } = render(
<div onClick={parentClick}>
<LoopResultPanel
list={[[createNodeTracing('1')]]}
onHide={vi.fn()}
onBack={vi.fn()}
/>
</div>,
)
const overlay = container.querySelector('.absolute.inset-0')
if (!overlay)
throw new Error('Expected overlay wrapper to be rendered')
fireEvent.click(overlay)
expect(parentClick).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,101 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { AgentLogItemWithChildren } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AgentLogNav from '../agent-log-nav'
import AgentLogNavMore from '../agent-log-nav-more'
import AgentResultPanel from '../agent-result-panel'
vi.mock('../agent-log-item', () => ({
default: ({ item, onShowAgentOrToolLog }: any) => (
<button type="button" onClick={() => onShowAgentOrToolLog(item)}>
item-{item.label}
</button>
),
}))
const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
message_id: 'message-1',
label: 'Planner',
children: [],
status: 'succeeded',
node_execution_id: 'exec-1',
node_id: 'node-1',
data: {},
...overrides,
})
describe('agent-log leaf components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The navigation and result views should expose stack navigation and nested agent log entries.
describe('Navigation and Results', () => {
it('should navigate back, open intermediate entries, and show the tail label', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const stack = [
createLogItem({ message_id: 'root', label: 'Strategy' }),
createLogItem({ message_id: 'mid', label: 'Tool A' }),
createLogItem({ message_id: 'tail', label: 'Tool B' }),
]
render(
<AgentLogNav
agentOrToolLogItemStack={stack}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
await user.click(screen.getByRole('button', { name: /^AGENT$/i }))
await user.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.strategy\.label$/ }))
await user.click(screen.getAllByRole('button')[2]!)
await user.click(screen.getByText('Tool A'))
expect(onShowAgentOrToolLog.mock.calls[0]).toHaveLength(0)
expect(onShowAgentOrToolLog).toHaveBeenNthCalledWith(2, stack[0])
expect(onShowAgentOrToolLog).toHaveBeenNthCalledWith(3, stack[1])
expect(screen.getByText('Tool B')).toBeInTheDocument()
})
it('should render the more menu options as shortcuts to nested logs', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const option = createLogItem({ message_id: 'mid', label: 'Intermediate Tool' })
render(
<AgentLogNavMore
options={[option]}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('Intermediate Tool'))
expect(onShowAgentOrToolLog).toHaveBeenCalledWith(option)
})
it('should render result items and the circular invocation warning', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const top = createLogItem({ message_id: 'top', label: 'Top', hasCircle: true })
const child = createLogItem({ message_id: 'child', label: 'Child Tool' })
render(
<AgentResultPanel
agentOrToolLogItemStack={[top]}
agentOrToolLogListMap={{ top: [child] }}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
expect(screen.getByText('runLog.circularInvocationTip')).toBeInTheDocument()
await user.click(screen.getByText('item-Child Tool'))
expect(onShowAgentOrToolLog).toHaveBeenCalledWith(child)
})
})
})

View File

@ -28,7 +28,7 @@ const AgentLogNavMore = ({
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button
className="h-6 w-6"
variant="ghost-accent"

View File

@ -0,0 +1,70 @@
import type { IterationDurationMap, NodeTracing } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import IterationResultPanel from '../iteration-result-panel'
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
default: ({ list }: { list: NodeTracing[] }) => (
<div data-testid="tracing-panel">
{list.map(item => (
<div key={`${item.node_id}-${item.execution_metadata?.iteration_index}`}>{item.node_id}</div>
))}
</div>
),
}))
const createTracing = (
nodeId: string,
status: NodeRunningStatus,
iterationIndex: number,
parallelModeRunId?: string,
): NodeTracing => {
return {
node_id: nodeId,
status,
execution_metadata: {
iteration_index: iterationIndex,
parallel_mode_run_id: parallelModeRunId,
},
} as NodeTracing
}
describe('IterationResultPanel integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render failed, running, and completed iterations and toggle tracing details', async () => {
const user = userEvent.setup()
const onBack = vi.fn()
const list: NodeTracing[][] = [
[createTracing('failed-node', NodeRunningStatus.Failed, 0, 'iter-1')],
[createTracing('running-node', NodeRunningStatus.Running, 1, 'iter-2')],
[createTracing('done-node', NodeRunningStatus.Succeeded, 2, 'iter-3')],
]
const durationMap: IterationDurationMap = {
'iter-3': 0.001,
}
const { container } = render(
<IterationResultPanel
list={list}
onBack={onBack}
iterDurationMap={durationMap}
/>,
)
expect(screen.getByText('0.01s')).toBeInTheDocument()
await user.click(screen.getByText('workflow.singleRun.back'))
expect(onBack).toHaveBeenCalledTimes(1)
await user.click(screen.getByText((_, node) => node?.textContent === 'workflow.singleRun.iteration 3'))
expect(container.querySelectorAll('.opacity-100')).toHaveLength(1)
expect(screen.getByText('done-node')).toBeInTheDocument()
await user.click(screen.getByText((_, node) => node?.textContent === 'workflow.singleRun.iteration 3'))
expect(container.querySelectorAll('.opacity-100')).toHaveLength(0)
})
})

View File

@ -0,0 +1,75 @@
/* eslint-disable ts/no-explicit-any */
import type { NodeTracing } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '../../../types'
import RetryResultPanel from '../retry-result-panel'
vi.mock('../../tracing-panel', () => ({
default: ({ list }: any) => (
<div>
{list.map((item: any) => (
<div key={item.id}>{item.title}</div>
))}
</div>
),
}))
const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'node-1',
node_type: BlockEnum.Code,
title: 'Code',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: 'succeeded',
error: '',
elapsed_time: 0.1,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 1,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
finished_at: 2,
...overrides,
})
describe('RetryResultPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The retry result panel should expose a back action and relabel each retry attempt in the tracing list.
describe('Rendering', () => {
it('should render retry titles and call onBack from the back header', async () => {
const user = userEvent.setup()
const onBack = vi.fn()
render(
<RetryResultPanel
list={[createTrace({ id: 'retry-1' }), createTrace({ id: 'retry-2' })]}
onBack={onBack}
/>,
)
expect(screen.getByText('workflow.nodes.common.retry.retry 1')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.retry.retry 2')).toBeInTheDocument()
await user.click(screen.getByText('workflow.singleRun.back'))
expect(onBack).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,138 @@
import { render, screen } from '@testing-library/react'
import { BlockEnum, NodeRunningStatus } from '../../types'
import SimpleNode from '../index'
let mockNodesReadOnly = false
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({
nodesReadOnly: mockNodesReadOnly,
}),
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
__esModule: true,
default: ({ type }: { type: BlockEnum }) => <div>{`block-icon:${type}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/node-control', () => ({
__esModule: true,
default: ({ id }: { id: string }) => <div>{`node-control:${id}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/node-handle', () => ({
NodeTargetHandle: ({ handleId }: { handleId: string }) => <div>{`node-handle:${handleId}`}</div>,
}))
const createData = (overrides: Record<string, unknown> = {}) => ({
title: 'Answer',
desc: '',
type: BlockEnum.Answer,
...overrides,
})
describe('simple-node', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodesReadOnly = false
})
it('should render the block shell, target handle, and node control by default', () => {
render(
<SimpleNode
id="simple-node"
data={createData()}
/>,
)
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(screen.getByText('block-icon:answer')).toBeInTheDocument()
expect(screen.getByText('node-handle:target')).toBeInTheDocument()
expect(screen.getByText('node-control:simple-node')).toBeInTheDocument()
})
it('should show the running state border and spinner', () => {
const { container } = render(
<SimpleNode
id="simple-node"
data={createData({
_runningStatus: NodeRunningStatus.Running,
})}
/>,
)
expect(container.querySelector('.text-text-accent')).not.toBeNull()
expect(container.innerHTML).toContain('!border-state-accent-solid')
expect(screen.queryByText('node-control:simple-node')).not.toBeInTheDocument()
})
it('should show success, failed, and exception status indicators', () => {
const { container, rerender } = render(
<SimpleNode
id="simple-node"
data={createData({
_runningStatus: NodeRunningStatus.Succeeded,
})}
/>,
)
expect(container.querySelector('.text-text-success')).not.toBeNull()
expect(container.innerHTML).toContain('!border-state-success-solid')
rerender(
<SimpleNode
id="simple-node"
data={createData({
_runningStatus: NodeRunningStatus.Failed,
})}
/>,
)
expect(container.querySelector('.text-text-destructive')).not.toBeNull()
expect(container.innerHTML).toContain('!border-state-destructive-solid')
rerender(
<SimpleNode
id="simple-node"
data={createData({
_runningStatus: NodeRunningStatus.Exception,
})}
/>,
)
expect(container.querySelector('.text-text-warning-secondary')).not.toBeNull()
expect(container.innerHTML).toContain('!border-state-warning-solid')
})
it('should hide handles and controls for candidate or read-only nodes and show selected waiting styles', () => {
mockNodesReadOnly = true
const { container } = render(
<SimpleNode
id="simple-node"
data={createData({
selected: true,
_waitingRun: true,
_isCandidate: true,
})}
/>,
)
expect(screen.queryByText('node-handle:target')).not.toBeInTheDocument()
expect(screen.queryByText('node-control:simple-node')).not.toBeInTheDocument()
expect(container.querySelector('.border-components-option-card-option-selected-border')).not.toBeNull()
expect(container.querySelector('.opacity-70')).not.toBeNull()
})
it('should show a spinner when a single run is still running', () => {
const { container } = render(
<SimpleNode
id="simple-node"
data={createData({
_singleRunningStatus: NodeRunningStatus.Running,
})}
/>,
)
expect(container.querySelector('.animate-spin')).not.toBeNull()
})
})

View File

@ -8939,7 +8939,7 @@
},
"app/components/workflow/panel/chat-record/user-input.tsx": {
"ts/no-explicit-any": {
"count": 2
"count": 1
}
},
"app/components/workflow/panel/chat-variable-panel/components/array-bool-list.tsx": {