mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
test(workflow): add helper specs and raise targeted workflow coverage (#33995)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,276 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import Tabs from '../tabs'
|
||||
import { TabsEnum } from '../types'
|
||||
|
||||
const {
|
||||
mockSetState,
|
||||
mockInvalidateBuiltInTools,
|
||||
mockToolsState,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSetState: vi.fn(),
|
||||
mockInvalidateBuiltInTools: vi.fn(),
|
||||
mockToolsState: {
|
||||
buildInTools: [{ icon: '/tool.svg', name: 'tool' }] as Array<{ icon: string | Record<string, string>, name: string }> | undefined,
|
||||
customTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined,
|
||||
workflowTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined,
|
||||
mcpTools: [] as Array<{ icon: string | Record<string, string>, name: string }> | undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({
|
||||
children,
|
||||
popupContent,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
popupContent: React.ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
<span>{popupContent}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({
|
||||
systemFeatures: { enable_marketplace: true },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useFeaturedToolsRecommendations: () => ({
|
||||
plugins: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => ({ data: mockToolsState.buildInTools }),
|
||||
useAllCustomTools: () => ({ data: mockToolsState.customTools }),
|
||||
useAllWorkflowTools: () => ({ data: mockToolsState.workflowTools }),
|
||||
useAllMCPTools: () => ({ data: mockToolsState.mcpTools }),
|
||||
useInvalidateAllBuiltInTools: () => mockInvalidateBuiltInTools,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '/console',
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
setState: mockSetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../all-start-blocks', () => ({
|
||||
default: () => <div>start-content</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../blocks', () => ({
|
||||
default: () => <div>blocks-content</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../data-sources', () => ({
|
||||
default: () => <div>sources-content</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../all-tools', () => ({
|
||||
default: (props: {
|
||||
buildInTools: Array<{ icon: string | Record<string, string> }>
|
||||
showFeatured: boolean
|
||||
featuredLoading: boolean
|
||||
onFeaturedInstallSuccess: () => Promise<void>
|
||||
}) => (
|
||||
<div>
|
||||
tools-content
|
||||
{props.buildInTools.map((tool, index) => (
|
||||
<span key={index}>
|
||||
{typeof tool.icon === 'string' ? tool.icon : 'object-icon'}
|
||||
</span>
|
||||
))}
|
||||
<span>{props.showFeatured ? 'featured-on' : 'featured-off'}</span>
|
||||
<span>{props.featuredLoading ? 'featured-loading' : 'featured-idle'}</span>
|
||||
<button onClick={() => props.onFeaturedInstallSuccess()}>Install featured tool</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Tabs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockToolsState.buildInTools = [{ icon: '/tool.svg', name: 'tool' }]
|
||||
mockToolsState.customTools = []
|
||||
mockToolsState.workflowTools = []
|
||||
mockToolsState.mcpTools = []
|
||||
})
|
||||
|
||||
const baseProps = {
|
||||
activeTab: TabsEnum.Start,
|
||||
onActiveTabChange: vi.fn(),
|
||||
searchText: '',
|
||||
tags: [],
|
||||
onTagsChange: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
blocks: [],
|
||||
tabs: [
|
||||
{ key: TabsEnum.Start, name: 'Start' },
|
||||
{ key: TabsEnum.Blocks, name: 'Blocks', disabled: true },
|
||||
{ key: TabsEnum.Tools, name: 'Tools' },
|
||||
],
|
||||
filterElem: <div>filter</div>,
|
||||
}
|
||||
|
||||
it('should render start content and disabled tab tooltip text', () => {
|
||||
render(<Tabs {...baseProps} />)
|
||||
|
||||
expect(screen.getByText('start-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.tabs.startDisabledTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch tabs through click handlers and render tools content with normalized icons', () => {
|
||||
const onActiveTabChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Tabs
|
||||
{...baseProps}
|
||||
activeTab={TabsEnum.Tools}
|
||||
onActiveTabChange={onActiveTabChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Start'))
|
||||
|
||||
expect(onActiveTabChange).toHaveBeenCalledWith(TabsEnum.Start)
|
||||
expect(screen.getByText('tools-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('/console/tool.svg')).toBeInTheDocument()
|
||||
expect(screen.getByText('featured-on')).toBeInTheDocument()
|
||||
expect(screen.getByText('featured-idle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should sync normalized tools into workflow store state', () => {
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
|
||||
|
||||
expect(mockSetState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore clicks on disabled and already active tabs', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onActiveTabChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Tabs
|
||||
{...baseProps}
|
||||
activeTab={TabsEnum.Start}
|
||||
onActiveTabChange={onActiveTabChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Start'))
|
||||
await user.click(screen.getByText('Blocks'))
|
||||
|
||||
expect(onActiveTabChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render sources content when the sources tab is active and data sources are provided', () => {
|
||||
render(
|
||||
<Tabs
|
||||
{...baseProps}
|
||||
activeTab={TabsEnum.Sources}
|
||||
dataSources={[{ name: 'dataset', icon: '/dataset.svg' } as never]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('sources-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the previous workflow store state when tool references do not change', () => {
|
||||
mockToolsState.buildInTools = [{ icon: '/console/already-prefixed.svg', name: 'tool' }]
|
||||
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
|
||||
|
||||
const previousState = {
|
||||
buildInTools: mockToolsState.buildInTools,
|
||||
customTools: mockToolsState.customTools,
|
||||
workflowTools: mockToolsState.workflowTools,
|
||||
mcpTools: mockToolsState.mcpTools,
|
||||
}
|
||||
const updateState = mockSetState.mock.calls[0][0] as (state: typeof previousState) => typeof previousState
|
||||
|
||||
expect(updateState(previousState)).toBe(previousState)
|
||||
})
|
||||
|
||||
it('should normalize every tool collection and merge updates into workflow store state', () => {
|
||||
mockToolsState.buildInTools = [{ icon: { light: '/tool.svg' }, name: 'tool' }]
|
||||
mockToolsState.customTools = [{ icon: '/custom.svg', name: 'custom' }]
|
||||
mockToolsState.workflowTools = [{ icon: '/workflow.svg', name: 'workflow' }]
|
||||
mockToolsState.mcpTools = [{ icon: '/mcp.svg', name: 'mcp' }]
|
||||
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
|
||||
|
||||
expect(screen.getByText('object-icon')).toBeInTheDocument()
|
||||
|
||||
const updateState = mockSetState.mock.calls[0][0] as (state: {
|
||||
buildInTools?: Array<{ icon: string | Record<string, string>, name: string }>
|
||||
customTools?: Array<{ icon: string | Record<string, string>, name: string }>
|
||||
workflowTools?: Array<{ icon: string | Record<string, string>, name: string }>
|
||||
mcpTools?: Array<{ icon: string | Record<string, string>, name: string }>
|
||||
}) => {
|
||||
buildInTools?: Array<{ icon: string | Record<string, string>, name: string }>
|
||||
customTools?: Array<{ icon: string | Record<string, string>, name: string }>
|
||||
workflowTools?: Array<{ icon: string | Record<string, string>, name: string }>
|
||||
mcpTools?: Array<{ icon: string | Record<string, string>, name: string }>
|
||||
}
|
||||
|
||||
expect(updateState({
|
||||
buildInTools: [],
|
||||
customTools: [],
|
||||
workflowTools: [],
|
||||
mcpTools: [],
|
||||
})).toEqual({
|
||||
buildInTools: [{ icon: { light: '/tool.svg' }, name: 'tool' }],
|
||||
customTools: [{ icon: '/console/custom.svg', name: 'custom' }],
|
||||
workflowTools: [{ icon: '/console/workflow.svg', name: 'workflow' }],
|
||||
mcpTools: [{ icon: '/console/mcp.svg', name: 'mcp' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip normalization when a tool list is undefined', () => {
|
||||
mockToolsState.buildInTools = undefined
|
||||
|
||||
render(<Tabs {...baseProps} activeTab={TabsEnum.Tools} />)
|
||||
|
||||
expect(screen.getByText('tools-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should force start content to render and invalidate built-in tools after featured installs', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Tabs
|
||||
{...baseProps}
|
||||
activeTab={TabsEnum.Tools}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Install featured tool' }))
|
||||
|
||||
expect(screen.getByText('tools-content')).toBeInTheDocument()
|
||||
expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render start content when blocks are hidden but forceShowStartContent is enabled', () => {
|
||||
render(
|
||||
<Tabs
|
||||
{...baseProps}
|
||||
activeTab={TabsEnum.Start}
|
||||
noBlocks
|
||||
forceShowStartContent
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('start-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -41,6 +41,122 @@ export type TabsProps = {
|
||||
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
|
||||
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
|
||||
}
|
||||
|
||||
const normalizeToolList = (list: ToolWithProvider[] | undefined, currentBasePath?: string) => {
|
||||
if (!list || !currentBasePath)
|
||||
return list
|
||||
|
||||
let changed = false
|
||||
const normalized = list.map((provider) => {
|
||||
if (typeof provider.icon !== 'string')
|
||||
return provider
|
||||
|
||||
const shouldPrefix = provider.icon.startsWith('/')
|
||||
&& !provider.icon.startsWith(`${currentBasePath}/`)
|
||||
|
||||
if (!shouldPrefix)
|
||||
return provider
|
||||
|
||||
changed = true
|
||||
return {
|
||||
...provider,
|
||||
icon: `${currentBasePath}${provider.icon}`,
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? normalized : list
|
||||
}
|
||||
|
||||
const getStoreToolUpdates = ({
|
||||
state,
|
||||
buildInTools,
|
||||
customTools,
|
||||
workflowTools,
|
||||
mcpTools,
|
||||
}: {
|
||||
state: {
|
||||
buildInTools?: ToolWithProvider[]
|
||||
customTools?: ToolWithProvider[]
|
||||
workflowTools?: ToolWithProvider[]
|
||||
mcpTools?: ToolWithProvider[]
|
||||
}
|
||||
buildInTools?: ToolWithProvider[]
|
||||
customTools?: ToolWithProvider[]
|
||||
workflowTools?: ToolWithProvider[]
|
||||
mcpTools?: ToolWithProvider[]
|
||||
}) => {
|
||||
const updates: Partial<typeof state> = {}
|
||||
|
||||
if (buildInTools !== undefined && state.buildInTools !== buildInTools)
|
||||
updates.buildInTools = buildInTools
|
||||
if (customTools !== undefined && state.customTools !== customTools)
|
||||
updates.customTools = customTools
|
||||
if (workflowTools !== undefined && state.workflowTools !== workflowTools)
|
||||
updates.workflowTools = workflowTools
|
||||
if (mcpTools !== undefined && state.mcpTools !== mcpTools)
|
||||
updates.mcpTools = mcpTools
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
const TabHeaderItem = ({
|
||||
tab,
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
disabledTip,
|
||||
}: {
|
||||
tab: TabsProps['tabs'][number]
|
||||
activeTab: TabsEnum
|
||||
onActiveTabChange: (activeTab: TabsEnum) => void
|
||||
disabledTip: string
|
||||
}) => {
|
||||
const className = cn(
|
||||
'relative mr-0.5 flex h-8 items-center rounded-t-lg px-3 system-sm-medium',
|
||||
tab.disabled
|
||||
? 'cursor-not-allowed text-text-disabled opacity-60'
|
||||
: activeTab === tab.key
|
||||
// eslint-disable-next-line tailwindcss/no-unknown-classes
|
||||
? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent'
|
||||
: 'cursor-pointer text-text-tertiary',
|
||||
)
|
||||
|
||||
const handleClick = () => {
|
||||
if (tab.disabled || activeTab === tab.key)
|
||||
return
|
||||
onActiveTabChange(tab.key)
|
||||
}
|
||||
|
||||
if (tab.disabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={tab.key}
|
||||
position="top"
|
||||
popupClassName="max-w-[200px]"
|
||||
popupContent={disabledTip}
|
||||
>
|
||||
<div
|
||||
className={className}
|
||||
aria-disabled={tab.disabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.key}
|
||||
className={className}
|
||||
aria-disabled={tab.disabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Tabs: FC<TabsProps> = ({
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
@ -71,51 +187,21 @@ const Tabs: FC<TabsProps> = ({
|
||||
plugins: featuredPlugins = [],
|
||||
isLoading: isFeaturedLoading,
|
||||
} = useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline)
|
||||
|
||||
const normalizeToolList = useMemo(() => {
|
||||
return (list?: ToolWithProvider[]) => {
|
||||
if (!list)
|
||||
return list
|
||||
if (!basePath)
|
||||
return list
|
||||
let changed = false
|
||||
const normalized = list.map((provider) => {
|
||||
if (typeof provider.icon === 'string') {
|
||||
const icon = provider.icon
|
||||
const shouldPrefix = Boolean(basePath)
|
||||
&& icon.startsWith('/')
|
||||
&& !icon.startsWith(`${basePath}/`)
|
||||
|
||||
if (shouldPrefix) {
|
||||
changed = true
|
||||
return {
|
||||
...provider,
|
||||
icon: `${basePath}${icon}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider
|
||||
})
|
||||
return changed ? normalized : list
|
||||
}
|
||||
}, [basePath])
|
||||
const normalizedBuiltInTools = useMemo(() => normalizeToolList(buildInTools, basePath), [buildInTools])
|
||||
const normalizedCustomTools = useMemo(() => normalizeToolList(customTools, basePath), [customTools])
|
||||
const normalizedWorkflowTools = useMemo(() => normalizeToolList(workflowTools, basePath), [workflowTools])
|
||||
const normalizedMcpTools = useMemo(() => normalizeToolList(mcpTools, basePath), [mcpTools])
|
||||
const disabledTip = t('tabs.startDisabledTip', { ns: 'workflow' })
|
||||
|
||||
useEffect(() => {
|
||||
workflowStore.setState((state) => {
|
||||
const updates: Partial<typeof state> = {}
|
||||
const normalizedBuiltIn = normalizeToolList(buildInTools)
|
||||
const normalizedCustom = normalizeToolList(customTools)
|
||||
const normalizedWorkflow = normalizeToolList(workflowTools)
|
||||
const normalizedMCP = normalizeToolList(mcpTools)
|
||||
|
||||
if (normalizedBuiltIn !== undefined && state.buildInTools !== normalizedBuiltIn)
|
||||
updates.buildInTools = normalizedBuiltIn
|
||||
if (normalizedCustom !== undefined && state.customTools !== normalizedCustom)
|
||||
updates.customTools = normalizedCustom
|
||||
if (normalizedWorkflow !== undefined && state.workflowTools !== normalizedWorkflow)
|
||||
updates.workflowTools = normalizedWorkflow
|
||||
if (normalizedMCP !== undefined && state.mcpTools !== normalizedMCP)
|
||||
updates.mcpTools = normalizedMCP
|
||||
const updates = getStoreToolUpdates({
|
||||
state,
|
||||
buildInTools: normalizedBuiltInTools,
|
||||
customTools: normalizedCustomTools,
|
||||
workflowTools: normalizedWorkflowTools,
|
||||
mcpTools: normalizedMcpTools,
|
||||
})
|
||||
if (!Object.keys(updates).length)
|
||||
return state
|
||||
return {
|
||||
@ -123,7 +209,7 @@ const Tabs: FC<TabsProps> = ({
|
||||
...updates,
|
||||
}
|
||||
})
|
||||
}, [workflowStore, normalizeToolList, buildInTools, customTools, workflowTools, mcpTools])
|
||||
}, [normalizedBuiltInTools, normalizedCustomTools, normalizedMcpTools, normalizedWorkflowTools, workflowStore])
|
||||
|
||||
return (
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
@ -131,46 +217,15 @@ const Tabs: FC<TabsProps> = ({
|
||||
!noBlocks && (
|
||||
<div className="relative flex bg-background-section-burn pl-1 pt-1">
|
||||
{
|
||||
tabs.map((tab) => {
|
||||
const commonProps = {
|
||||
'className': cn(
|
||||
'system-sm-medium relative mr-0.5 flex h-8 items-center rounded-t-lg px-3',
|
||||
tab.disabled
|
||||
? 'cursor-not-allowed text-text-disabled opacity-60'
|
||||
: activeTab === tab.key
|
||||
? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent'
|
||||
: 'cursor-pointer text-text-tertiary',
|
||||
),
|
||||
'aria-disabled': tab.disabled,
|
||||
'onClick': () => {
|
||||
if (tab.disabled || activeTab === tab.key)
|
||||
return
|
||||
onActiveTabChange(tab.key)
|
||||
},
|
||||
} as const
|
||||
if (tab.disabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={tab.key}
|
||||
position="top"
|
||||
popupClassName="max-w-[200px]"
|
||||
popupContent={t('tabs.startDisabledTip', { ns: 'workflow' })}
|
||||
>
|
||||
<div {...commonProps}>
|
||||
{tab.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={tab.key}
|
||||
{...commonProps}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
tabs.map(tab => (
|
||||
<TabHeaderItem
|
||||
key={tab.key}
|
||||
tab={tab}
|
||||
activeTab={activeTab}
|
||||
onActiveTabChange={onActiveTabChange}
|
||||
disabledTip={disabledTip}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
@ -219,10 +274,10 @@ const Tabs: FC<TabsProps> = ({
|
||||
onSelect={onSelect}
|
||||
tags={tags}
|
||||
canNotSelectMultiple
|
||||
buildInTools={buildInTools || []}
|
||||
customTools={customTools || []}
|
||||
workflowTools={workflowTools || []}
|
||||
mcpTools={mcpTools || []}
|
||||
buildInTools={normalizedBuiltInTools || []}
|
||||
customTools={normalizedCustomTools || []}
|
||||
workflowTools={normalizedWorkflowTools || []}
|
||||
mcpTools={normalizedMcpTools || []}
|
||||
onTagsChange={onTagsChange}
|
||||
isInRAGPipeline={inRAGPipeline}
|
||||
featuredPlugins={featuredPlugins}
|
||||
|
||||
Reference in New Issue
Block a user