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:
Coding On Star
2026-03-24 17:51:07 +08:00
committed by GitHub
parent 67d5c9d148
commit a408a5d87e
75 changed files with 9402 additions and 2507 deletions

View File

@ -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()
})
})

View File

@ -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}