mirror of
https://github.com/langgenius/dify.git
synced 2026-03-23 23:37:55 +08:00
test(workflow): add unit tests for workflow components (#33910)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
@ -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>
|
||||
))}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
106
web/app/components/workflow/header/__tests__/index.spec.tsx
Normal file
106
web/app/components/workflow/header/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
107
web/app/components/workflow/nodes/__tests__/index.spec.tsx
Normal file
107
web/app/components/workflow/nodes/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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())
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
138
web/app/components/workflow/simple-node/__tests__/index.spec.tsx
Normal file
138
web/app/components/workflow/simple-node/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user