test(web): add comprehensive unit and integration tests for plugins and tools modules (#32220)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star
2026-02-12 10:04:56 +08:00
committed by GitHub
parent 10f85074e8
commit d6b025e91e
195 changed files with 12219 additions and 7840 deletions

View File

@ -0,0 +1,263 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ProviderList from '../provider-list'
let mockActiveTab = 'builtin'
const mockSetActiveTab = vi.fn((val: string) => {
mockActiveTab = val
})
vi.mock('nuqs', () => ({
useQueryState: () => [mockActiveTab, mockSetActiveTab],
}))
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
tags: [],
tagsMap: {},
getTagLabel: (name: string) => name,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({ enable_marketplace: false }),
}))
const mockCollections = [
{
id: 'builtin-1',
name: 'google-search',
author: 'Dify',
description: { en_US: 'Google Search', zh_Hans: '谷歌搜索' },
icon: 'icon-google',
label: { en_US: 'Google Search', zh_Hans: '谷歌搜索' },
type: 'builtin',
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: ['search'],
},
{
id: 'api-1',
name: 'my-api',
author: 'User',
description: { en_US: 'My API tool', zh_Hans: '我的 API 工具' },
icon: { background: '#fff', content: '🔧' },
label: { en_US: 'My API Tool', zh_Hans: '我的 API 工具' },
type: 'api',
team_credentials: {},
is_team_authorization: false,
allow_delete: true,
labels: [],
},
{
id: 'workflow-1',
name: 'wf-tool',
author: 'User',
description: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
icon: { background: '#fff', content: '⚡' },
label: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
type: 'workflow',
team_credentials: {},
is_team_authorization: false,
allow_delete: true,
labels: [],
},
]
const mockRefetch = vi.fn()
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({
data: mockCollections,
refetch: mockRefetch,
}),
}))
vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: () => ({ data: null }),
useInvalidateInstalledPluginList: () => vi.fn(),
}))
vi.mock('@/app/components/base/tab-slider-new', () => ({
default: ({ value, onChange, options }: {
value: string
onChange: (val: string) => void
options: { value: string, text: string }[]
}) => (
<div data-testid="tab-slider">
{options.map(opt => (
<button
key={opt.value}
data-testid={`tab-${opt.value}`}
data-active={value === opt.value}
onClick={() => onChange(opt.value)}
>
{opt.text}
</button>
))}
</div>
),
}))
vi.mock('@/app/components/plugins/card', () => ({
default: ({ payload, className }: { payload: { name: string }, className?: string }) => (
<div data-testid={`card-${payload.name}`} className={className}>{payload.name}</div>
),
}))
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
default: ({ tags }: { tags: string[] }) => <div data-testid="card-more-info">{tags.join(', ')}</div>,
}))
vi.mock('@/app/components/tools/labels/filter', () => ({
default: ({ value, onChange }: { value: string[], onChange: (v: string[]) => void }) => (
<div data-testid="label-filter">
<button data-testid="add-filter" onClick={() => onChange(['search'])}>Add filter</button>
<button data-testid="clear-filter" onClick={() => onChange([])}>Clear filter</button>
<span>{value.join(', ')}</span>
</div>
),
}))
vi.mock('@/app/components/tools/provider/custom-create-card', () => ({
default: () => <div data-testid="custom-create-card">Create Custom Tool</div>,
}))
vi.mock('@/app/components/tools/provider/detail', () => ({
default: ({ collection, onHide }: { collection: { name: string }, onHide: () => void }) => (
<div data-testid="provider-detail">
<span>{collection.name}</span>
<button data-testid="detail-close" onClick={onHide}>Close</button>
</div>
),
}))
vi.mock('@/app/components/tools/provider/empty', () => ({
default: () => <div data-testid="workflow-empty">No workflow tools</div>,
}))
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
default: ({ detail }: { detail: unknown }) =>
detail ? <div data-testid="plugin-detail-panel" /> : null,
}))
vi.mock('@/app/components/plugins/marketplace/empty', () => ({
default: ({ text }: { text: string }) => <div data-testid="empty">{text}</div>,
}))
vi.mock('../marketplace', () => ({
default: () => <div data-testid="marketplace">Marketplace</div>,
}))
vi.mock('../marketplace/hooks', () => ({
useMarketplace: () => ({
isLoading: false,
marketplaceCollections: [],
marketplaceCollectionPluginsMap: {},
plugins: [],
handleScroll: vi.fn(),
page: 1,
}),
}))
vi.mock('../mcp', () => ({
default: ({ searchText }: { searchText: string }) => (
<div data-testid="mcp-list">
MCP List:
{searchText}
</div>
),
}))
describe('ProviderList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockActiveTab = 'builtin'
})
afterEach(() => {
cleanup()
})
describe('Tab Navigation', () => {
it('renders all four tabs', () => {
render(<ProviderList />)
expect(screen.getByTestId('tab-builtin')).toHaveTextContent('tools.type.builtIn')
expect(screen.getByTestId('tab-api')).toHaveTextContent('tools.type.custom')
expect(screen.getByTestId('tab-workflow')).toHaveTextContent('tools.type.workflow')
expect(screen.getByTestId('tab-mcp')).toHaveTextContent('MCP')
})
it('switches tab when clicked', () => {
render(<ProviderList />)
fireEvent.click(screen.getByTestId('tab-api'))
expect(mockSetActiveTab).toHaveBeenCalledWith('api')
})
})
describe('Filtering', () => {
it('shows only builtin collections by default', () => {
render(<ProviderList />)
expect(screen.getByTestId('card-google-search')).toBeInTheDocument()
expect(screen.queryByTestId('card-my-api')).not.toBeInTheDocument()
})
it('filters by search keyword', () => {
render(<ProviderList />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'nonexistent' } })
expect(screen.queryByTestId('card-google-search')).not.toBeInTheDocument()
})
it('shows label filter for non-MCP tabs', () => {
render(<ProviderList />)
expect(screen.getByTestId('label-filter')).toBeInTheDocument()
})
it('renders search input', () => {
render(<ProviderList />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
describe('Custom Tab', () => {
it('shows custom create card when on api tab', () => {
mockActiveTab = 'api'
render(<ProviderList />)
expect(screen.getByTestId('custom-create-card')).toBeInTheDocument()
})
})
describe('Workflow Tab', () => {
it('shows empty state when no workflow collections', () => {
mockActiveTab = 'workflow'
render(<ProviderList />)
// Only one workflow collection exists, so it should show
expect(screen.getByTestId('card-wf-tool')).toBeInTheDocument()
})
})
describe('MCP Tab', () => {
it('renders MCPList component', () => {
mockActiveTab = 'mcp'
render(<ProviderList />)
expect(screen.getByTestId('mcp-list')).toBeInTheDocument()
})
})
describe('Provider Detail', () => {
it('opens provider detail when a non-plugin collection is clicked', () => {
render(<ProviderList />)
fireEvent.click(screen.getByTestId('card-google-search'))
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
expect(screen.getByTestId('provider-detail')).toHaveTextContent('google-search')
})
it('closes provider detail when close button is clicked', () => {
render(<ProviderList />)
fireEvent.click(screen.getByTestId('card-google-search'))
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('detail-close'))
expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument()
})
})
})

View File

@ -2,7 +2,7 @@ import type { Credential } from '@/app/components/tools/types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
import ConfigCredential from './config-credentials'
import ConfigCredential from '../config-credentials'
describe('ConfigCredential', () => {
const baseCredential: Credential = {

View File

@ -1,8 +1,8 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { importSchemaFromURL } from '@/service/tools'
import Toast from '../../base/toast'
import examples from './examples'
import GetSchema from './get-schema'
import Toast from '../../../base/toast'
import examples from '../examples'
import GetSchema from '../get-schema'
vi.mock('@/service/tools', () => ({
importSchemaFromURL: vi.fn(),

View File

@ -6,7 +6,7 @@ import Toast from '@/app/components/base/toast'
import { Plan } from '@/app/components/billing/type'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
import { parseParamsSchema } from '@/service/tools'
import EditCustomCollectionModal from './index'
import EditCustomCollectionModal from '../index'
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')

View File

@ -3,7 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
import { testAPIAvailable } from '@/service/tools'
import TestApi from './test-api'
import TestApi from '../test-api'
vi.mock('@/service/tools', () => ({
testAPIAvailable: vi.fn(),

View File

@ -1,6 +1,6 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LabelFilter from './filter'
import LabelFilter from '../filter'
// Mock useTags hook with controlled test data
const mockTags = [

View File

@ -1,6 +1,6 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LabelSelector from './selector'
import LabelSelector from '../selector'
// Mock useTags hook with controlled test data
const mockTags = [

View File

@ -0,0 +1,41 @@
import type { Label } from '../constant'
import { beforeEach, describe, expect, it } from 'vitest'
import { useStore } from '../store'
describe('labels/store', () => {
beforeEach(() => {
// Reset store to initial state before each test
useStore.setState({ labelList: [] })
})
it('initializes with empty labelList', () => {
const state = useStore.getState()
expect(state.labelList).toEqual([])
})
it('sets labelList via setLabelList', () => {
const labels: Label[] = [
{ name: 'search', label: 'Search' },
{ name: 'agent', label: { en_US: 'Agent', zh_Hans: '代理' } },
]
useStore.getState().setLabelList(labels)
expect(useStore.getState().labelList).toEqual(labels)
})
it('replaces existing labels with new list', () => {
const initial: Label[] = [{ name: 'old', label: 'Old' }]
useStore.getState().setLabelList(initial)
expect(useStore.getState().labelList).toEqual(initial)
const updated: Label[] = [{ name: 'new', label: 'New' }]
useStore.getState().setLabelList(updated)
expect(useStore.getState().labelList).toEqual(updated)
})
it('handles undefined argument (sets labelList to undefined)', () => {
const labels: Label[] = [{ name: 'test', label: 'Test' }]
useStore.getState().setLabelList(labels)
useStore.getState().setLabelList(undefined)
expect(useStore.getState().labelList).toBeUndefined()
})
})

View File

@ -0,0 +1,201 @@
import type { Plugin } from '@/app/components/plugins/types'
import type { Collection } from '@/app/components/tools/types'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { useMarketplace } from '../hooks'
// ==================== Mock Setup ====================
const mockQueryMarketplaceCollectionsAndPlugins = vi.fn()
const mockQueryPlugins = vi.fn()
const mockQueryPluginsWithDebounced = vi.fn()
const mockResetPlugins = vi.fn()
const mockFetchNextPage = vi.fn()
const mockUseMarketplaceCollectionsAndPlugins = vi.fn()
const mockUseMarketplacePlugins = vi.fn()
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => mockUseMarketplaceCollectionsAndPlugins(...args),
useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args),
}))
const mockUseAllToolProviders = vi.fn()
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: (...args: unknown[]) => mockUseAllToolProviders(...args),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: vi.fn(() => 'https://marketplace.test/market'),
}))
// ==================== Test Utilities ====================
const createToolProvider = (overrides: Partial<Collection> = {}): Collection => ({
id: 'provider-1',
name: 'Provider 1',
author: 'Author',
description: { en_US: 'desc', zh_Hans: '描述' },
icon: 'icon',
label: { en_US: 'label', zh_Hans: '标签' },
type: CollectionType.custom,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
...overrides,
})
const setupHookMocks = (overrides?: {
isLoading?: boolean
isPluginsLoading?: boolean
pluginsPage?: number
hasNextPage?: boolean
plugins?: Plugin[] | undefined
}) => {
mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({
isLoading: overrides?.isLoading ?? false,
marketplaceCollections: [],
marketplaceCollectionPluginsMap: {},
queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins,
})
mockUseMarketplacePlugins.mockReturnValue({
plugins: overrides?.plugins,
resetPlugins: mockResetPlugins,
queryPlugins: mockQueryPlugins,
queryPluginsWithDebounced: mockQueryPluginsWithDebounced,
isLoading: overrides?.isPluginsLoading ?? false,
fetchNextPage: mockFetchNextPage,
hasNextPage: overrides?.hasNextPage ?? false,
page: overrides?.pluginsPage,
})
}
// ==================== Tests ====================
describe('useMarketplace', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAllToolProviders.mockReturnValue({
data: [],
isSuccess: true,
})
setupHookMocks()
})
describe('Queries', () => {
it('should query plugins with debounce when search text is provided', async () => {
mockUseAllToolProviders.mockReturnValue({
data: [
createToolProvider({ plugin_id: 'plugin-a' }),
createToolProvider({ plugin_id: undefined }),
],
isSuccess: true,
})
renderHook(() => useMarketplace('alpha', []))
await waitFor(() => {
expect(mockQueryPluginsWithDebounced).toHaveBeenCalledWith({
category: PluginCategoryEnum.tool,
query: 'alpha',
tags: [],
exclude: ['plugin-a'],
type: 'plugin',
})
})
expect(mockQueryMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled()
expect(mockResetPlugins).not.toHaveBeenCalled()
})
it('should query plugins immediately when only tags are provided', async () => {
mockUseAllToolProviders.mockReturnValue({
data: [createToolProvider({ plugin_id: 'plugin-b' })],
isSuccess: true,
})
renderHook(() => useMarketplace('', ['tag-1']))
await waitFor(() => {
expect(mockQueryPlugins).toHaveBeenCalledWith({
category: PluginCategoryEnum.tool,
query: '',
tags: ['tag-1'],
exclude: ['plugin-b'],
type: 'plugin',
})
})
})
it('should query collections and reset plugins when no filters are provided', async () => {
mockUseAllToolProviders.mockReturnValue({
data: [createToolProvider({ plugin_id: 'plugin-c' })],
isSuccess: true,
})
renderHook(() => useMarketplace('', []))
await waitFor(() => {
expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({
category: PluginCategoryEnum.tool,
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
exclude: ['plugin-c'],
type: 'plugin',
})
})
expect(mockResetPlugins).toHaveBeenCalledTimes(1)
})
})
describe('State', () => {
it('should expose combined loading state and fallback page value', () => {
setupHookMocks({ isLoading: true, isPluginsLoading: false, pluginsPage: undefined })
const { result } = renderHook(() => useMarketplace('', []))
expect(result.current.isLoading).toBe(true)
expect(result.current.page).toBe(1)
})
})
describe('Scroll', () => {
it('should fetch next page when scrolling near bottom with filters', () => {
setupHookMocks({ hasNextPage: true })
const { result } = renderHook(() => useMarketplace('search', []))
const event = {
target: {
scrollTop: 100,
scrollHeight: 200,
clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD,
},
} as unknown as Event
act(() => {
result.current.handleScroll(event)
})
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
})
it('should not fetch next page when no filters are applied', () => {
setupHookMocks({ hasNextPage: true })
const { result } = renderHook(() => useMarketplace('', []))
const event = {
target: {
scrollTop: 100,
scrollHeight: 200,
clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD,
},
} as unknown as Event
act(() => {
result.current.handleScroll(event)
})
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,180 @@
import type { useMarketplace } from '../hooks'
import type { Plugin } from '@/app/components/plugins/types'
import type { Collection } from '@/app/components/tools/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { getMarketplaceUrl } from '@/utils/var'
import Marketplace from '../index'
const listRenderSpy = vi.fn()
vi.mock('@/app/components/plugins/marketplace/list', () => ({
default: (props: {
marketplaceCollections: unknown[]
marketplaceCollectionPluginsMap: Record<string, unknown[]>
plugins?: unknown[]
showInstallButton?: boolean
}) => {
listRenderSpy(props)
return <div data-testid="marketplace-list" />
},
}))
const mockUseMarketplaceCollectionsAndPlugins = vi.fn()
const mockUseMarketplacePlugins = vi.fn()
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => mockUseMarketplaceCollectionsAndPlugins(...args),
useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args),
}))
const mockUseAllToolProviders = vi.fn()
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: (...args: unknown[]) => mockUseAllToolProviders(...args),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: vi.fn(() => 'https://marketplace.test/market'),
}))
const mockGetMarketplaceUrl = vi.mocked(getMarketplaceUrl)
const _createToolProvider = (overrides: Partial<Collection> = {}): Collection => ({
id: 'provider-1',
name: 'Provider 1',
author: 'Author',
description: { en_US: 'desc', zh_Hans: '描述' },
icon: 'icon',
label: { en_US: 'label', zh_Hans: '标签' },
type: CollectionType.custom,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
...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' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const createMarketplaceContext = (overrides: Partial<ReturnType<typeof useMarketplace>> = {}) => ({
isLoading: false,
marketplaceCollections: [],
marketplaceCollectionPluginsMap: {},
plugins: [],
handleScroll: vi.fn(),
page: 1,
...overrides,
})
describe('Marketplace', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering the marketplace panel based on loading and visibility state.
describe('Rendering', () => {
it('should show loading indicator when loading first page', () => {
// Arrange
const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 })
render(
<Marketplace
searchPluginText=""
filterPluginTags={[]}
isMarketplaceArrowVisible={false}
showMarketplacePanel={vi.fn()}
marketplaceContext={marketplaceContext}
/>,
)
// Assert
expect(document.querySelector('svg.spin-animation')).toBeInTheDocument()
expect(screen.queryByTestId('marketplace-list')).not.toBeInTheDocument()
})
it('should render list when not loading', () => {
// Arrange
const marketplaceContext = createMarketplaceContext({
isLoading: false,
plugins: [createPlugin()],
})
render(
<Marketplace
searchPluginText=""
filterPluginTags={[]}
isMarketplaceArrowVisible={false}
showMarketplacePanel={vi.fn()}
marketplaceContext={marketplaceContext}
/>,
)
// Assert
expect(screen.getByTestId('marketplace-list')).toBeInTheDocument()
expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({
showInstallButton: true,
}))
})
})
// Prop-driven UI output such as links and action triggers.
describe('Props', () => {
it('should build marketplace link and trigger panel when arrow is clicked', async () => {
const user = userEvent.setup()
// Arrange
const marketplaceContext = createMarketplaceContext()
const showMarketplacePanel = vi.fn()
const { container } = render(
<Marketplace
searchPluginText="vector"
filterPluginTags={['tag-a', 'tag-b']}
isMarketplaceArrowVisible
showMarketplacePanel={showMarketplacePanel}
marketplaceContext={marketplaceContext}
/>,
)
// Act
const arrowIcon = container.querySelector('svg.cursor-pointer')
expect(arrowIcon).toBeTruthy()
await user.click(arrowIcon as SVGElement)
// Assert
expect(showMarketplacePanel).toHaveBeenCalledTimes(1)
expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('', {
language: 'en',
q: 'vector',
tags: 'tag-a,tag-b',
theme: undefined,
})
const marketplaceLink = screen.getByRole('link', { name: /plugin.marketplace.difyMarketplace/i })
expect(marketplaceLink).toHaveAttribute('href', 'https://marketplace.test/market')
})
})
})
// useMarketplace hook tests moved to hooks.spec.ts

View File

@ -1,360 +0,0 @@
import type { Plugin } from '@/app/components/plugins/types'
import type { Collection } from '@/app/components/tools/types'
import { act, render, renderHook, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { getMarketplaceUrl } from '@/utils/var'
import { useMarketplace } from './hooks'
import Marketplace from './index'
const listRenderSpy = vi.fn()
vi.mock('@/app/components/plugins/marketplace/list', () => ({
default: (props: {
marketplaceCollections: unknown[]
marketplaceCollectionPluginsMap: Record<string, unknown[]>
plugins?: unknown[]
showInstallButton?: boolean
}) => {
listRenderSpy(props)
return <div data-testid="marketplace-list" />
},
}))
const mockUseMarketplaceCollectionsAndPlugins = vi.fn()
const mockUseMarketplacePlugins = vi.fn()
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => mockUseMarketplaceCollectionsAndPlugins(...args),
useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args),
}))
const mockUseAllToolProviders = vi.fn()
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: (...args: unknown[]) => mockUseAllToolProviders(...args),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: vi.fn(() => 'https://marketplace.test/market'),
}))
vi.mock('next-themes', () => ({
useTheme: () => ({ theme: 'light' }),
}))
const mockGetMarketplaceUrl = vi.mocked(getMarketplaceUrl)
const createToolProvider = (overrides: Partial<Collection> = {}): Collection => ({
id: 'provider-1',
name: 'Provider 1',
author: 'Author',
description: { en_US: 'desc', zh_Hans: '描述' },
icon: 'icon',
label: { en_US: 'label', zh_Hans: '标签' },
type: CollectionType.custom,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
...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' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const createMarketplaceContext = (overrides: Partial<ReturnType<typeof useMarketplace>> = {}) => ({
isLoading: false,
marketplaceCollections: [],
marketplaceCollectionPluginsMap: {},
plugins: [],
handleScroll: vi.fn(),
page: 1,
...overrides,
})
describe('Marketplace', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering the marketplace panel based on loading and visibility state.
describe('Rendering', () => {
it('should show loading indicator when loading first page', () => {
// Arrange
const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 })
render(
<Marketplace
searchPluginText=""
filterPluginTags={[]}
isMarketplaceArrowVisible={false}
showMarketplacePanel={vi.fn()}
marketplaceContext={marketplaceContext}
/>,
)
// Assert
expect(document.querySelector('svg.spin-animation')).toBeInTheDocument()
expect(screen.queryByTestId('marketplace-list')).not.toBeInTheDocument()
})
it('should render list when not loading', () => {
// Arrange
const marketplaceContext = createMarketplaceContext({
isLoading: false,
plugins: [createPlugin()],
})
render(
<Marketplace
searchPluginText=""
filterPluginTags={[]}
isMarketplaceArrowVisible={false}
showMarketplacePanel={vi.fn()}
marketplaceContext={marketplaceContext}
/>,
)
// Assert
expect(screen.getByTestId('marketplace-list')).toBeInTheDocument()
expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({
showInstallButton: true,
}))
})
})
// Prop-driven UI output such as links and action triggers.
describe('Props', () => {
it('should build marketplace link and trigger panel when arrow is clicked', async () => {
const user = userEvent.setup()
// Arrange
const marketplaceContext = createMarketplaceContext()
const showMarketplacePanel = vi.fn()
const { container } = render(
<Marketplace
searchPluginText="vector"
filterPluginTags={['tag-a', 'tag-b']}
isMarketplaceArrowVisible
showMarketplacePanel={showMarketplacePanel}
marketplaceContext={marketplaceContext}
/>,
)
// Act
const arrowIcon = container.querySelector('svg.cursor-pointer')
expect(arrowIcon).toBeTruthy()
await user.click(arrowIcon as SVGElement)
// Assert
expect(showMarketplacePanel).toHaveBeenCalledTimes(1)
expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('', {
language: 'en',
q: 'vector',
tags: 'tag-a,tag-b',
theme: 'light',
})
const marketplaceLink = screen.getByRole('link', { name: /plugin.marketplace.difyMarketplace/i })
expect(marketplaceLink).toHaveAttribute('href', 'https://marketplace.test/market')
})
})
})
describe('useMarketplace', () => {
const mockQueryMarketplaceCollectionsAndPlugins = vi.fn()
const mockQueryPlugins = vi.fn()
const mockQueryPluginsWithDebounced = vi.fn()
const mockResetPlugins = vi.fn()
const mockFetchNextPage = vi.fn()
const setupHookMocks = (overrides?: {
isLoading?: boolean
isPluginsLoading?: boolean
pluginsPage?: number
hasNextPage?: boolean
plugins?: Plugin[] | undefined
}) => {
mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({
isLoading: overrides?.isLoading ?? false,
marketplaceCollections: [],
marketplaceCollectionPluginsMap: {},
queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins,
})
mockUseMarketplacePlugins.mockReturnValue({
plugins: overrides?.plugins,
resetPlugins: mockResetPlugins,
queryPlugins: mockQueryPlugins,
queryPluginsWithDebounced: mockQueryPluginsWithDebounced,
isLoading: overrides?.isPluginsLoading ?? false,
fetchNextPage: mockFetchNextPage,
hasNextPage: overrides?.hasNextPage ?? false,
page: overrides?.pluginsPage,
})
}
beforeEach(() => {
vi.clearAllMocks()
mockUseAllToolProviders.mockReturnValue({
data: [],
isSuccess: true,
})
setupHookMocks()
})
// Query behavior driven by search filters and provider exclusions.
describe('Queries', () => {
it('should query plugins with debounce when search text is provided', async () => {
// Arrange
mockUseAllToolProviders.mockReturnValue({
data: [
createToolProvider({ plugin_id: 'plugin-a' }),
createToolProvider({ plugin_id: undefined }),
],
isSuccess: true,
})
// Act
renderHook(() => useMarketplace('alpha', []))
// Assert
await waitFor(() => {
expect(mockQueryPluginsWithDebounced).toHaveBeenCalledWith({
category: PluginCategoryEnum.tool,
query: 'alpha',
tags: [],
exclude: ['plugin-a'],
type: 'plugin',
})
})
expect(mockQueryMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled()
expect(mockResetPlugins).not.toHaveBeenCalled()
})
it('should query plugins immediately when only tags are provided', async () => {
// Arrange
mockUseAllToolProviders.mockReturnValue({
data: [createToolProvider({ plugin_id: 'plugin-b' })],
isSuccess: true,
})
// Act
renderHook(() => useMarketplace('', ['tag-1']))
// Assert
await waitFor(() => {
expect(mockQueryPlugins).toHaveBeenCalledWith({
category: PluginCategoryEnum.tool,
query: '',
tags: ['tag-1'],
exclude: ['plugin-b'],
type: 'plugin',
})
})
})
it('should query collections and reset plugins when no filters are provided', async () => {
// Arrange
mockUseAllToolProviders.mockReturnValue({
data: [createToolProvider({ plugin_id: 'plugin-c' })],
isSuccess: true,
})
// Act
renderHook(() => useMarketplace('', []))
// Assert
await waitFor(() => {
expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({
category: PluginCategoryEnum.tool,
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
exclude: ['plugin-c'],
type: 'plugin',
})
})
expect(mockResetPlugins).toHaveBeenCalledTimes(1)
})
})
// State derived from hook inputs and loading signals.
describe('State', () => {
it('should expose combined loading state and fallback page value', () => {
// Arrange
setupHookMocks({ isLoading: true, isPluginsLoading: false, pluginsPage: undefined })
// Act
const { result } = renderHook(() => useMarketplace('', []))
// Assert
expect(result.current.isLoading).toBe(true)
expect(result.current.page).toBe(1)
})
})
// Scroll handling that triggers pagination when appropriate.
describe('Scroll', () => {
it('should fetch next page when scrolling near bottom with filters', () => {
// Arrange
setupHookMocks({ hasNextPage: true })
const { result } = renderHook(() => useMarketplace('search', []))
const event = {
target: {
scrollTop: 100,
scrollHeight: 200,
clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD,
},
} as unknown as Event
// Act
act(() => {
result.current.handleScroll(event)
})
// Assert
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
})
it('should not fetch next page when no filters are applied', () => {
// Arrange
setupHookMocks({ hasNextPage: true })
const { result } = renderHook(() => useMarketplace('', []))
const event = {
target: {
scrollTop: 100,
scrollHeight: 200,
clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD,
},
} as unknown as Event
// Act
act(() => {
result.current.handleScroll(event)
})
// Assert
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
})
})

View File

@ -3,7 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NewMCPCard from './create-card'
import NewMCPCard from '../create-card'
// Track the mock functions
const mockCreateMCP = vi.fn().mockResolvedValue({ id: 'new-mcp-id', name: 'New MCP' })
@ -22,7 +22,7 @@ type MockMCPModalProps = {
onHide: () => void
}
vi.mock('./modal', () => ({
vi.mock('../modal', () => ({
default: ({ show, onConfirm, onHide }: MockMCPModalProps) => {
if (!show)
return null

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import HeadersInput from './headers-input'
import HeadersInput from '../headers-input'
describe('HeadersInput', () => {
const defaultProps = {

View File

@ -1,6 +1,6 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MCPList from './index'
import MCPList from '../index'
type MockProvider = {
id: string
@ -22,7 +22,7 @@ vi.mock('@/service/use-tools', () => ({
}))
// Mock child components
vi.mock('./create-card', () => ({
vi.mock('../create-card', () => ({
default: ({ handleCreate }: { handleCreate: (provider: { id: string, name: string }) => void }) => (
<div data-testid="create-card" onClick={() => handleCreate({ id: 'new-id', name: 'New Provider' })}>
Create Card
@ -30,7 +30,7 @@ vi.mock('./create-card', () => ({
),
}))
vi.mock('./provider-card', () => ({
vi.mock('../provider-card', () => ({
default: ({ data, handleSelect, onUpdate, onDeleted }: { data: MockProvider, handleSelect: (id: string) => void, onUpdate: (id: string) => void, onDeleted: () => void }) => {
const displayName = typeof data.name === 'string' ? data.name : Object.values(data.name)[0]
return (
@ -43,7 +43,7 @@ vi.mock('./provider-card', () => ({
},
}))
vi.mock('./detail/provider-detail', () => ({
vi.mock('../detail/provider-detail', () => ({
default: ({ detail, onHide, onUpdate, isTriggerAuthorize, onFirstCreate }: { detail: MockDetail, onHide: () => void, onUpdate: () => void, isTriggerAuthorize: boolean, onFirstCreate: () => void }) => {
const displayName = detail?.name
? (typeof detail.name === 'string' ? detail.name : Object.values(detail.name)[0])

View File

@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPServerModal from './mcp-server-modal'
import MCPServerModal from '../mcp-server-modal'
// Mock the services
vi.mock('@/service/use-tools', () => ({

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import MCPServerParamItem from './mcp-server-param-item'
import MCPServerParamItem from '../mcp-server-param-item'
describe('MCPServerParamItem', () => {
const defaultProps = {

View File

@ -7,7 +7,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum } from '@/types/app'
import MCPServiceCard from './mcp-service-card'
import MCPServiceCard from '../mcp-service-card'
// Mock MCPServerModal
vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({
@ -96,7 +96,7 @@ const createDefaultHookState = (): MockHookState => ({
let mockHookState = createDefaultHookState()
// Mock the hook - uses mockHookState which can be modified per test
vi.mock('./hooks/use-mcp-service-card', () => ({
vi.mock('../hooks/use-mcp-service-card', () => ({
useMCPServiceCardState: () => ({
...mockHookState,
handleStatusChange: mockHandleStatusChange,

View File

@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPModal from './modal'
import MCPModal from '../modal'
// Mock the service API
vi.mock('@/service/common', () => ({

View File

@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPCard from './provider-card'
import MCPCard from '../provider-card'
// Mutable mock functions
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
@ -32,7 +32,7 @@ type MCPModalProps = {
onHide: () => void
}
vi.mock('./modal', () => ({
vi.mock('../modal', () => ({
default: ({ show, onConfirm, onHide }: MCPModalProps) => {
if (!show)
return null
@ -81,7 +81,7 @@ type OperationDropdownProps = {
onOpenChange: (open: boolean) => void
}
vi.mock('./detail/operation-dropdown', () => ({
vi.mock('../detail/operation-dropdown', () => ({
default: ({ onEdit, onRemove, onOpenChange }: OperationDropdownProps) => (
<div data-testid="operation-dropdown">
<button

View File

@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPDetailContent from './content'
import MCPDetailContent from '../content'
// Mutable mock functions
const mockUpdateTools = vi.fn().mockResolvedValue({})
@ -67,7 +67,7 @@ type MCPModalProps = {
onHide: () => void
}
vi.mock('../modal', () => ({
vi.mock('../../modal', () => ({
default: ({ show, onConfirm, onHide }: MCPModalProps) => {
if (!show)
return null
@ -99,7 +99,7 @@ vi.mock('@/app/components/base/confirm', () => ({
}))
// Mock OperationDropdown
vi.mock('./operation-dropdown', () => ({
vi.mock('../operation-dropdown', () => ({
default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => (
<div data-testid="operation-dropdown">
<button data-testid="edit-btn" onClick={onEdit}>Edit</button>
@ -113,7 +113,7 @@ type ToolItemData = {
name: string
}
vi.mock('./tool-item', () => ({
vi.mock('../tool-item', () => ({
default: ({ tool }: { tool: ToolItemData }) => (
<div data-testid="tool-item">{tool.name}</div>
),

View File

@ -1,6 +1,6 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ListLoading from './list-loading'
import ListLoading from '../list-loading'
describe('ListLoading', () => {
describe('Rendering', () => {

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OperationDropdown from './operation-dropdown'
import OperationDropdown from '../operation-dropdown'
describe('OperationDropdown', () => {
const defaultProps = {

View File

@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPDetailPanel from './provider-detail'
import MCPDetailPanel from '../provider-detail'
// Mock the drawer component
vi.mock('@/app/components/base/drawer', () => ({
@ -16,7 +16,7 @@ vi.mock('@/app/components/base/drawer', () => ({
}))
// Mock the content component to expose onUpdate callback
vi.mock('./content', () => ({
vi.mock('../content', () => ({
default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => (
<div data-testid="mcp-detail-content">
{detail.name}

View File

@ -1,7 +1,7 @@
import type { Tool } from '@/app/components/tools/types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import MCPToolItem from './tool-item'
import MCPToolItem from '../tool-item'
describe('MCPToolItem', () => {
const createMockTool = (overrides = {}): Tool => ({

View File

@ -3,7 +3,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { isValidServerID, isValidUrl, useMCPModalForm } from './use-mcp-modal-form'
import { isValidServerID, isValidUrl, useMCPModalForm } from '../use-mcp-modal-form'
// Mock the API service
vi.mock('@/service/common', () => ({

View File

@ -6,7 +6,7 @@ import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum } from '@/types/app'
import { useMCPServiceCardState } from './use-mcp-service-card'
import { useMCPServiceCardState } from '../use-mcp-service-card'
// Mutable mock data for MCP server detail
let mockMCPServerDetailData: {

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import AuthenticationSection from './authentication-section'
import AuthenticationSection from '../authentication-section'
describe('AuthenticationSection', () => {
const defaultProps = {

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ConfigurationsSection from './configurations-section'
import ConfigurationsSection from '../configurations-section'
describe('ConfigurationsSection', () => {
const defaultProps = {

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import HeadersSection from './headers-section'
import HeadersSection from '../headers-section'
describe('HeadersSection', () => {
const defaultProps = {

View File

@ -1,8 +1,8 @@
import type { CustomCollectionBackend } from '../types'
import type { CustomCollectionBackend } from '../../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthType } from '../types'
import CustomCreateCard from './custom-create-card'
import { AuthType } from '../../types'
import CustomCreateCard from '../custom-create-card'
// Mock workspace manager state
let mockIsWorkspaceManager = true

View File

@ -0,0 +1,713 @@
import type { Collection } from '../../types'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthType, CollectionType } from '../../types'
import ProviderDetail from '../detail'
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/i18n-config/language', () => ({
getLanguage: () => 'en_US',
}))
const mockIsCurrentWorkspaceManager = vi.fn(() => true)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
}),
}))
const mockSetShowModelModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowModelModal: mockSetShowModelModal,
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [
{ provider: 'model-collection-id', name: 'TestModel' },
],
}),
}))
const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([])
const mockFetchCustomToolList = vi.fn().mockResolvedValue([])
const mockFetchModelToolList = vi.fn().mockResolvedValue([])
const mockFetchCustomCollection = vi.fn().mockResolvedValue({
credentials: { auth_type: 'none' },
})
const mockFetchWorkflowToolDetail = vi.fn().mockResolvedValue({
workflow_app_id: 'wf-123',
workflow_tool_id: 'wt-456',
tool: { parameters: [], labels: [] },
})
const mockUpdateBuiltInToolCredential = vi.fn().mockResolvedValue({})
const mockRemoveBuiltInToolCredential = vi.fn().mockResolvedValue({})
const mockUpdateCustomCollection = vi.fn().mockResolvedValue({})
const mockRemoveCustomCollection = vi.fn().mockResolvedValue({})
const mockDeleteWorkflowTool = vi.fn().mockResolvedValue({})
const mockSaveWorkflowToolProvider = vi.fn().mockResolvedValue({})
vi.mock('@/service/tools', () => ({
fetchBuiltInToolList: (...args: unknown[]) => mockFetchBuiltInToolList(...args),
fetchCustomToolList: (...args: unknown[]) => mockFetchCustomToolList(...args),
fetchModelToolList: (...args: unknown[]) => mockFetchModelToolList(...args),
fetchCustomCollection: (...args: unknown[]) => mockFetchCustomCollection(...args),
fetchWorkflowToolDetail: (...args: unknown[]) => mockFetchWorkflowToolDetail(...args),
updateBuiltInToolCredential: (...args: unknown[]) => mockUpdateBuiltInToolCredential(...args),
removeBuiltInToolCredential: (...args: unknown[]) => mockRemoveBuiltInToolCredential(...args),
updateCustomCollection: (...args: unknown[]) => mockUpdateCustomCollection(...args),
removeCustomCollection: (...args: unknown[]) => mockRemoveCustomCollection(...args),
deleteWorkflowTool: (...args: unknown[]) => mockDeleteWorkflowTool(...args),
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
}))
vi.mock('@/service/use-tools', () => ({
useInvalidateAllWorkflowTools: () => vi.fn(),
}))
vi.mock('@/utils/var', () => ({
basePath: '',
}))
vi.mock('@/app/components/base/drawer', () => ({
default: ({ children, isOpen }: { children: React.ReactNode, isOpen: boolean }) =>
isOpen ? <div data-testid="drawer">{children}</div> : null,
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) =>
isShow
? (
<div data-testid="confirm-dialog">
<span>{title}</span>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
</div>
)
: null,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/app/components/header/indicator', () => ({
default: () => <span data-testid="indicator" />,
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: () => <span data-testid="card-icon" />,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName }: { orgName: string }) => <span data-testid="org-info">{orgName}</span>,
}))
vi.mock('@/app/components/plugins/card/base/title', () => ({
default: ({ title }: { title: string }) => <span data-testid="title">{title}</span>,
}))
vi.mock('../tool-item', () => ({
default: ({ tool }: { tool: { name: string } }) => <div data-testid={`tool-${tool.name}`}>{tool.name}</div>,
}))
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
default: ({ onHide, onEdit, onRemove }: { onHide: () => void, onEdit: (data: unknown) => void, onRemove: () => void }) => (
<div data-testid="edit-custom-modal">
<button data-testid="edit-save" onClick={() => onEdit({ labels: ['test'] })}>Save</button>
<button data-testid="edit-remove" onClick={onRemove}>Remove</button>
<button data-testid="edit-close" onClick={onHide}>Close</button>
</div>
),
}))
vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
default: ({ onCancel, onSaved, onRemove }: { onCancel: () => void, onSaved: (val: Record<string, string>) => Promise<void>, onRemove: () => Promise<void> }) => (
<div data-testid="config-credential">
<button data-testid="credential-save" onClick={() => onSaved({ key: 'val' })}>Save</button>
<button data-testid="credential-remove" onClick={onRemove}>Remove</button>
<button data-testid="credential-cancel" onClick={onCancel}>Cancel</button>
</div>
),
}))
vi.mock('@/app/components/tools/workflow-tool', () => ({
default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
<div data-testid="workflow-tool-modal">
<button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}>Save</button>
<button data-testid="wf-remove" onClick={onRemove}>Remove</button>
<button data-testid="wf-close" onClick={onHide}>Close</button>
</div>
),
}))
const createMockCollection = (overrides?: Partial<Collection>): Collection => ({
id: 'test-id',
name: 'test-collection',
author: 'Test Author',
description: { en_US: 'A test collection', zh_Hans: '测试集合' },
icon: 'icon-url',
label: { en_US: 'Test Collection', zh_Hans: '测试集合' },
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: ['search'],
...overrides,
})
describe('ProviderDetail', () => {
const mockOnHide = vi.fn()
const mockOnRefreshData = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockFetchBuiltInToolList.mockResolvedValue([
{ name: 'tool-1', label: { en_US: 'Tool 1' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} },
{ name: 'tool-2', label: { en_US: 'Tool 2' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} },
])
mockFetchCustomToolList.mockResolvedValue([])
mockFetchModelToolList.mockResolvedValue([])
})
afterEach(() => {
cleanup()
})
describe('Rendering', () => {
it('renders title, org info and description for a builtIn collection', async () => {
render(
<ProviderDetail
collection={createMockCollection()}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
expect(screen.getByTestId('title')).toHaveTextContent('Test Collection')
expect(screen.getByTestId('org-info')).toHaveTextContent('Test Author')
expect(screen.getByTestId('description')).toHaveTextContent('A test collection')
})
it('shows loading state initially', () => {
render(
<ProviderDetail
collection={createMockCollection()}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('renders tool list after loading for builtIn type', async () => {
render(
<ProviderDetail
collection={createMockCollection()}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('tool-tool-1')).toBeInTheDocument()
expect(screen.getByTestId('tool-tool-2')).toBeInTheDocument()
})
})
it('hides description when description is empty', () => {
render(
<ProviderDetail
collection={createMockCollection({ description: { en_US: '', zh_Hans: '' } })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
expect(screen.queryByTestId('description')).not.toBeInTheDocument()
})
})
describe('BuiltIn Collection Auth', () => {
it('shows "Set up credentials" button when not authorized and allow_delete', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: false })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
})
it('shows "Authorized" button when authorized and allow_delete', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: true })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
})
})
})
describe('Custom Collection', () => {
it('fetches custom collection and shows edit button', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: { auth_type: 'none' },
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchCustomCollection).toHaveBeenCalledWith('test-collection')
})
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
})
})
describe('Workflow Collection', () => {
it('fetches workflow tool detail and shows workflow buttons', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-id')
})
await waitFor(() => {
expect(screen.getByText('tools.openInStudio')).toBeInTheDocument()
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
})
})
describe('Model Collection', () => {
it('opens model modal when clicking auth button for model type', async () => {
mockFetchModelToolList.mockResolvedValue([
{ name: 'model-tool-1', label: { en_US: 'MT1' }, description: { en_US: '' }, parameters: [], labels: [], author: '', output_schema: {} },
])
render(
<ProviderDetail
collection={createMockCollection({
id: 'model-collection-id',
type: CollectionType.model,
is_team_authorization: false,
allow_delete: true,
})}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
expect(mockSetShowModelModal).toHaveBeenCalled()
})
})
describe('Close Action', () => {
it('calls onHide when close button is clicked', () => {
render(
<ProviderDetail
collection={createMockCollection()}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
expect(mockOnHide).toHaveBeenCalled()
})
})
describe('API calls by collection type', () => {
it('calls fetchBuiltInToolList for builtIn type', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.builtIn })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchBuiltInToolList).toHaveBeenCalledWith('test-collection')
})
})
it('calls fetchModelToolList for model type', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.model })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchModelToolList).toHaveBeenCalledWith('test-collection')
})
})
it('calls fetchCustomToolList for custom type', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchCustomToolList).toHaveBeenCalledWith('test-collection')
})
})
})
describe('BuiltIn Auth Flow', () => {
it('opens ConfigCredential when clicking auth button for builtIn type', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: false })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
})
it('saves credentials and refreshes data', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: false })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
await act(async () => {
fireEvent.click(screen.getByTestId('credential-save'))
})
await waitFor(() => {
expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test-collection', { key: 'val' })
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
it('removes credentials and refreshes data', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: false })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
await act(async () => {
fireEvent.click(screen.getByTestId('credential-remove'))
})
await waitFor(() => {
expect(mockRemoveBuiltInToolCredential).toHaveBeenCalledWith('test-collection')
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
it('opens auth modal from Authorized button for builtIn type', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: true })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.authorized'))
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
})
})
describe('Model Auth Flow', () => {
it('calls onRefreshData via model modal onSaveCallback', async () => {
render(
<ProviderDetail
collection={createMockCollection({
id: 'model-collection-id',
type: CollectionType.model,
is_team_authorization: false,
allow_delete: true,
})}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
const call = mockSetShowModelModal.mock.calls[0][0]
act(() => {
call.onSaveCallback()
})
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
describe('Custom Collection Operations', () => {
it('sets api_key_header_prefix when auth_type is apiKey and has value', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: {
auth_type: AuthType.apiKey,
api_key_value: 'secret-key',
},
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchCustomCollection).toHaveBeenCalled()
})
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
})
it('opens edit modal and saves custom collection', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: { auth_type: 'none' },
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('edit-save'))
})
await waitFor(() => {
expect(mockUpdateCustomCollection).toHaveBeenCalledWith({ labels: ['test'] })
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
it('removes custom collection via delete confirmation', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: { auth_type: 'none' },
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
fireEvent.click(screen.getByTestId('edit-remove'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('confirm-btn'))
})
await waitFor(() => {
expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test-collection')
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
})
describe('Workflow Collection Operations', () => {
it('displays workflow tool parameters', async () => {
mockFetchWorkflowToolDetail.mockResolvedValue({
workflow_app_id: 'wf-123',
workflow_tool_id: 'wt-456',
tool: {
parameters: [
{ name: 'query', type: 'string', llm_description: 'Search query', form: 'llm', required: true },
{ name: 'limit', type: 'number', llm_description: 'Max results', form: 'form', required: false },
],
labels: ['search'],
},
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('query')).toBeInTheDocument()
expect(screen.getByText('string')).toBeInTheDocument()
expect(screen.getByText('Search query')).toBeInTheDocument()
expect(screen.getByText('limit')).toBeInTheDocument()
})
})
it('saves workflow tool via workflow modal', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('wf-save'))
})
await waitFor(() => {
expect(mockSaveWorkflowToolProvider).toHaveBeenCalledWith({ name: 'test' })
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
it('removes workflow tool via delete confirmation', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
fireEvent.click(screen.getByTestId('wf-remove'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('confirm-btn'))
})
await waitFor(() => {
expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-id')
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
})
describe('Modal Close Actions', () => {
it('closes ConfigCredential when cancel is clicked', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: false })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('credential-cancel'))
expect(screen.queryByTestId('config-credential')).not.toBeInTheDocument()
})
it('closes EditCustomToolModal via onHide', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: { auth_type: 'none' },
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('edit-close'))
expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument()
})
it('closes WorkflowToolModal via onHide', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('wf-close'))
expect(screen.queryByTestId('workflow-tool-modal')).not.toBeInTheDocument()
})
})
describe('Delete Confirmation', () => {
it('cancels delete confirmation', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: { auth_type: 'none' },
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
fireEvent.click(screen.getByTestId('edit-remove'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('cancel-btn'))
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
})

View File

@ -2,9 +2,9 @@ import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import the mock to control it in tests
import useTheme from '@/hooks/use-theme'
import { ToolTypeEnum } from '../../workflow/block-selector/types'
import { ToolTypeEnum } from '../../../workflow/block-selector/types'
import Empty from './empty'
import Empty from '../empty'
// Mock useTheme hook
vi.mock('@/hooks/use-theme', () => ({

View File

@ -1,7 +1,7 @@
import type { Collection, Tool } from '../types'
import type { Collection, Tool } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ToolItem from './tool-item'
import ToolItem from '../tool-item'
// Mock useLocale hook
vi.mock('@/context/i18n', () => ({

View File

@ -0,0 +1,188 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ConfigCredential from '../config-credentials'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
const mockFetchCredentialSchema = vi.fn()
const mockFetchCredentialValue = vi.fn()
vi.mock('@/service/tools', () => ({
fetchBuiltInToolCredentialSchema: (...args: unknown[]) => mockFetchCredentialSchema(...args),
fetchBuiltInToolCredential: (...args: unknown[]) => mockFetchCredentialValue(...args),
}))
vi.mock('../../../utils/to-form-schema', () => ({
toolCredentialToFormSchemas: (schemas: unknown[]) => (schemas as Record<string, unknown>[]).map(s => ({
...s,
variable: s.name,
show_on: [],
})),
addDefaultValue: (value: Record<string, unknown>, _schemas: unknown[]) => ({ ...value }),
}))
vi.mock('@/app/components/base/drawer-plus', () => ({
default: ({ body, title, onHide }: { body: React.ReactNode, title: string, onHide: () => void }) => (
<div data-testid="drawer">
<span data-testid="drawer-title">{title}</span>
<button data-testid="drawer-close" onClick={onHide}>Close</button>
{body}
</div>
),
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
default: ({ value, onChange }: { value: Record<string, string>, onChange: (v: Record<string, string>) => void }) => (
<div data-testid="form">
<input
data-testid="form-input"
value={value.api_key || ''}
onChange={e => onChange({ ...value, api_key: e.target.value })}
/>
</div>
),
}))
const createMockCollection = (overrides?: Record<string, unknown>) => ({
id: 'test-collection',
name: 'test-tool',
author: 'Test',
description: { en_US: 'Test', zh_Hans: '测试' },
icon: '',
label: { en_US: 'Test', zh_Hans: '测试' },
type: 'builtin',
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
...overrides,
})
describe('ConfigCredential', () => {
const mockOnCancel = vi.fn()
const mockOnSaved = vi.fn().mockResolvedValue(undefined)
const mockOnRemove = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockFetchCredentialSchema.mockResolvedValue([
{ name: 'api_key', label: { en_US: 'API Key' }, type: 'secret-input', required: true },
])
mockFetchCredentialValue.mockResolvedValue({ api_key: 'sk-existing' })
})
afterEach(() => {
cleanup()
})
it('shows loading state initially then renders form', async () => {
render(
<ConfigCredential
collection={createMockCollection() as never}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
/>,
)
expect(screen.getByRole('status')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByTestId('form')).toBeInTheDocument()
})
})
it('renders drawer with correct title', async () => {
render(
<ConfigCredential
collection={createMockCollection() as never}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
/>,
)
expect(screen.getByTestId('drawer-title')).toHaveTextContent('tools.auth.setupModalTitle')
})
it('calls onCancel when cancel button is clicked', async () => {
render(
<ConfigCredential
collection={createMockCollection() as never}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('form')).toBeInTheDocument()
})
const cancelBtn = screen.getByText('common.operation.cancel')
fireEvent.click(cancelBtn)
expect(mockOnCancel).toHaveBeenCalled()
})
it('calls onSaved with credential values when save is clicked', async () => {
render(
<ConfigCredential
collection={createMockCollection() as never}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('form')).toBeInTheDocument()
})
const saveBtn = screen.getByText('common.operation.save')
fireEvent.click(saveBtn)
await waitFor(() => {
expect(mockOnSaved).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'sk-existing' }))
})
})
it('shows remove button when team is authorized and isHideRemoveBtn is false', async () => {
render(
<ConfigCredential
collection={createMockCollection({ is_team_authorization: true }) as never}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
onRemove={mockOnRemove}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('form')).toBeInTheDocument()
})
expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
})
it('hides remove button when isHideRemoveBtn is true', async () => {
render(
<ConfigCredential
collection={createMockCollection({ is_team_authorization: true }) as never}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
onRemove={mockOnRemove}
isHideRemoveBtn
/>,
)
await waitFor(() => {
expect(screen.getByTestId('form')).toBeInTheDocument()
})
expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument()
})
it('fetches credential schema for the collection name', async () => {
render(
<ConfigCredential
collection={createMockCollection() as never}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
/>,
)
await waitFor(() => {
expect(mockFetchCredentialSchema).toHaveBeenCalledWith('test-tool')
expect(mockFetchCredentialValue).toHaveBeenCalledWith('test-tool')
})
})
})

View File

@ -0,0 +1,82 @@
import type { ThoughtItem } from '@/app/components/base/chat/chat/type'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { describe, expect, it } from 'vitest'
import { addFileInfos, sortAgentSorts } from '../index'
describe('tools/utils', () => {
describe('sortAgentSorts', () => {
it('returns null/undefined input as-is', () => {
expect(sortAgentSorts(null as unknown as ThoughtItem[])).toBeNull()
expect(sortAgentSorts(undefined as unknown as ThoughtItem[])).toBeUndefined()
})
it('returns unsorted when some items lack position', () => {
const items = [
{ id: '1', position: 2 },
{ id: '2' },
] as unknown as ThoughtItem[]
const result = sortAgentSorts(items)
expect(result[0]).toEqual(expect.objectContaining({ id: '1' }))
expect(result[1]).toEqual(expect.objectContaining({ id: '2' }))
})
it('sorts items by position ascending', () => {
const items = [
{ id: 'c', position: 3 },
{ id: 'a', position: 1 },
{ id: 'b', position: 2 },
] as unknown as ThoughtItem[]
const result = sortAgentSorts(items)
expect(result.map((item: ThoughtItem & { id: string }) => item.id)).toEqual(['a', 'b', 'c'])
})
it('does not mutate the original array', () => {
const items = [
{ id: 'b', position: 2 },
{ id: 'a', position: 1 },
] as unknown as ThoughtItem[]
const result = sortAgentSorts(items)
expect(result).not.toBe(items)
})
})
describe('addFileInfos', () => {
it('returns null/undefined input as-is', () => {
expect(addFileInfos(null as unknown as ThoughtItem[], [])).toBeNull()
expect(addFileInfos(undefined as unknown as ThoughtItem[], [])).toBeUndefined()
})
it('returns items when messageFiles is null', () => {
const items = [{ id: '1' }] as unknown as ThoughtItem[]
expect(addFileInfos(items, null as unknown as FileEntity[])).toEqual(items)
})
it('adds message_files by matching file IDs', () => {
const file1 = { id: 'file-1', name: 'doc.pdf' } as FileEntity
const file2 = { id: 'file-2', name: 'img.png' } as FileEntity
const items = [
{ id: '1', files: ['file-1', 'file-2'] },
{ id: '2', files: [] },
] as unknown as ThoughtItem[]
const result = addFileInfos(items, [file1, file2])
expect((result[0] as ThoughtItem & { message_files: FileEntity[] }).message_files).toEqual([file1, file2])
})
it('returns items without files unchanged', () => {
const items = [
{ id: '1' },
{ id: '2', files: null },
] as unknown as ThoughtItem[]
const result = addFileInfos(items, [])
expect(result[0]).toEqual(expect.objectContaining({ id: '1' }))
})
it('does not mutate original items', () => {
const file1 = { id: 'file-1', name: 'doc.pdf' } as FileEntity
const items = [{ id: '1', files: ['file-1'] }] as unknown as ThoughtItem[]
const result = addFileInfos(items, [file1])
expect(result[0]).not.toBe(items[0])
})
})
})

View File

@ -0,0 +1,408 @@
import type { TriggerEventParameter } from '../../../plugins/types'
import type { ToolCredential, ToolParameter } from '../../types'
import { describe, expect, it } from 'vitest'
import {
addDefaultValue,
generateAgentToolValue,
generateFormValue,
getConfiguredValue,
getPlainValue,
getStructureValue,
toolCredentialToFormSchemas,
toolParametersToFormSchemas,
toType,
triggerEventParametersToFormSchemas,
} from '../to-form-schema'
describe('to-form-schema utilities', () => {
describe('toType', () => {
it('converts "string" to "text-input"', () => {
expect(toType('string')).toBe('text-input')
})
it('converts "number" to "number-input"', () => {
expect(toType('number')).toBe('number-input')
})
it('converts "boolean" to "checkbox"', () => {
expect(toType('boolean')).toBe('checkbox')
})
it('returns the original type for unknown types', () => {
expect(toType('select')).toBe('select')
expect(toType('secret-input')).toBe('secret-input')
expect(toType('file')).toBe('file')
})
})
describe('triggerEventParametersToFormSchemas', () => {
it('returns empty array for null/undefined parameters', () => {
expect(triggerEventParametersToFormSchemas(null as unknown as TriggerEventParameter[])).toEqual([])
expect(triggerEventParametersToFormSchemas([])).toEqual([])
})
it('maps parameters with type conversion and tooltip from description', () => {
const params = [
{
name: 'query',
type: 'string',
description: { en_US: 'Search query', zh_Hans: '搜索查询' },
label: { en_US: 'Query', zh_Hans: '查询' },
required: true,
form: 'llm',
},
] as unknown as TriggerEventParameter[]
const result = triggerEventParametersToFormSchemas(params)
expect(result).toHaveLength(1)
expect(result[0].type).toBe('text-input')
expect(result[0]._type).toBe('string')
expect(result[0].tooltip).toEqual({ en_US: 'Search query', zh_Hans: '搜索查询' })
})
it('preserves all original fields via spread', () => {
const params = [
{
name: 'count',
type: 'number',
description: { en_US: 'Count', zh_Hans: '数量' },
label: { en_US: 'Count', zh_Hans: '数量' },
required: false,
form: 'form',
},
] as unknown as TriggerEventParameter[]
const result = triggerEventParametersToFormSchemas(params)
expect(result[0].name).toBe('count')
expect(result[0].label).toEqual({ en_US: 'Count', zh_Hans: '数量' })
expect(result[0].required).toBe(false)
})
})
describe('toolParametersToFormSchemas', () => {
it('returns empty array for null parameters', () => {
expect(toolParametersToFormSchemas(null as unknown as ToolParameter[])).toEqual([])
})
it('converts parameters with variable = name and type conversion', () => {
const params: ToolParameter[] = [
{
name: 'input_text',
label: { en_US: 'Input', zh_Hans: '输入' },
human_description: { en_US: 'Enter text', zh_Hans: '输入文本' },
type: 'string',
form: 'llm',
llm_description: 'The input text',
required: true,
multiple: false,
default: 'hello',
},
]
const result = toolParametersToFormSchemas(params)
expect(result).toHaveLength(1)
expect(result[0].variable).toBe('input_text')
expect(result[0].type).toBe('text-input')
expect(result[0]._type).toBe('string')
expect(result[0].show_on).toEqual([])
expect(result[0].tooltip).toEqual({ en_US: 'Enter text', zh_Hans: '输入文本' })
})
it('maps options with show_on = []', () => {
const params: ToolParameter[] = [
{
name: 'mode',
label: { en_US: 'Mode', zh_Hans: '模式' },
human_description: { en_US: 'Select mode', zh_Hans: '选择模式' },
type: 'select',
form: 'form',
llm_description: '',
required: false,
multiple: false,
default: 'fast',
options: [
{ label: { en_US: 'Fast', zh_Hans: '快速' }, value: 'fast' },
{ label: { en_US: 'Accurate', zh_Hans: '精确' }, value: 'accurate' },
],
},
]
const result = toolParametersToFormSchemas(params)
expect(result[0].options).toHaveLength(2)
expect(result[0].options![0].show_on).toEqual([])
expect(result[0].options![1].show_on).toEqual([])
})
it('handles parameters without options', () => {
const params: ToolParameter[] = [
{
name: 'flag',
label: { en_US: 'Flag', zh_Hans: '标记' },
human_description: { en_US: 'Enable', zh_Hans: '启用' },
type: 'boolean',
form: 'form',
llm_description: '',
required: false,
multiple: false,
default: 'false',
},
]
const result = toolParametersToFormSchemas(params)
expect(result[0].options).toBeUndefined()
})
})
describe('toolCredentialToFormSchemas', () => {
it('returns empty array for null parameters', () => {
expect(toolCredentialToFormSchemas(null as unknown as ToolCredential[])).toEqual([])
})
it('converts credentials with variable = name and tooltip from help', () => {
const creds: ToolCredential[] = [
{
name: 'api_key',
label: { en_US: 'API Key', zh_Hans: 'API 密钥' },
help: { en_US: 'Enter your API key', zh_Hans: '输入你的 API 密钥' },
placeholder: { en_US: 'sk-xxx', zh_Hans: 'sk-xxx' },
type: 'secret-input',
required: true,
default: '',
},
]
const result = toolCredentialToFormSchemas(creds)
expect(result).toHaveLength(1)
expect(result[0].variable).toBe('api_key')
expect(result[0].type).toBe('secret-input')
expect(result[0].tooltip).toEqual({ en_US: 'Enter your API key', zh_Hans: '输入你的 API 密钥' })
expect(result[0].show_on).toEqual([])
})
it('handles null help field → tooltip becomes undefined', () => {
const creds: ToolCredential[] = [
{
name: 'token',
label: { en_US: 'Token', zh_Hans: '令牌' },
help: null,
placeholder: { en_US: '', zh_Hans: '' },
type: 'string',
required: false,
default: '',
},
]
const result = toolCredentialToFormSchemas(creds)
expect(result[0].tooltip).toBeUndefined()
})
it('maps credential options with show_on = []', () => {
const creds: ToolCredential[] = [
{
name: 'auth_method',
label: { en_US: 'Auth', zh_Hans: '认证' },
help: null,
placeholder: { en_US: '', zh_Hans: '' },
type: 'select',
required: true,
default: 'bearer',
options: [
{ label: { en_US: 'Bearer', zh_Hans: 'Bearer' }, value: 'bearer' },
{ label: { en_US: 'Basic', zh_Hans: 'Basic' }, value: 'basic' },
],
},
]
const result = toolCredentialToFormSchemas(creds)
expect(result[0].options).toHaveLength(2)
result[0].options!.forEach(opt => expect(opt.show_on).toEqual([]))
})
})
describe('addDefaultValue', () => {
it('fills in default when value is empty/null/undefined', () => {
const schemas = [
{ variable: 'name', type: 'text-input', default: 'default-name' },
{ variable: 'count', type: 'number-input', default: 10 },
]
const result = addDefaultValue({}, schemas)
expect(result.name).toBe('default-name')
expect(result.count).toBe(10)
})
it('does not override existing values', () => {
const schemas = [{ variable: 'name', type: 'text-input', default: 'default' }]
const result = addDefaultValue({ name: 'existing' }, schemas)
expect(result.name).toBe('existing')
})
it('fills default for empty string value', () => {
const schemas = [{ variable: 'name', type: 'text-input', default: 'default' }]
const result = addDefaultValue({ name: '' }, schemas)
expect(result.name).toBe('default')
})
it('converts string boolean values to proper boolean type', () => {
const schemas = [{ variable: 'flag', type: 'boolean' }]
expect(addDefaultValue({ flag: 'true' }, schemas).flag).toBe(true)
expect(addDefaultValue({ flag: 'false' }, schemas).flag).toBe(false)
expect(addDefaultValue({ flag: '1' }, schemas).flag).toBe(true)
expect(addDefaultValue({ flag: 'True' }, schemas).flag).toBe(true)
expect(addDefaultValue({ flag: '0' }, schemas).flag).toBe(false)
})
it('converts number boolean values to proper boolean type', () => {
const schemas = [{ variable: 'flag', type: 'boolean' }]
expect(addDefaultValue({ flag: 1 }, schemas).flag).toBe(true)
expect(addDefaultValue({ flag: 0 }, schemas).flag).toBe(false)
})
it('preserves actual boolean values', () => {
const schemas = [{ variable: 'flag', type: 'boolean' }]
expect(addDefaultValue({ flag: true }, schemas).flag).toBe(true)
expect(addDefaultValue({ flag: false }, schemas).flag).toBe(false)
})
})
describe('generateFormValue', () => {
it('generates constant-type value wrapper for defaults', () => {
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
const result = generateFormValue({}, schemas)
expect(result.name).toBeDefined()
const wrapper = result.name as { value: { type: string, value: unknown } }
// correctInitialData sets type to 'mixed' for text-input but preserves default value
expect(wrapper.value.type).toBe('mixed')
expect(wrapper.value.value).toBe('hello')
})
it('skips values that already exist', () => {
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
const result = generateFormValue({ name: 'existing' }, schemas)
expect(result.name).toBeUndefined()
})
it('generates auto:1 for reasoning mode', () => {
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
const result = generateFormValue({}, schemas, true)
expect(result.name).toEqual({ auto: 1, value: null })
})
it('handles boolean default conversion in non-reasoning mode', () => {
const schemas = [{ variable: 'flag', type: 'boolean', default: 'true' }]
const result = generateFormValue({}, schemas)
const wrapper = result.flag as { value: { type: string, value: unknown } }
expect(wrapper.value.value).toBe(true)
})
it('handles number-input default conversion', () => {
const schemas = [{ variable: 'count', type: 'number-input', default: '42' }]
const result = generateFormValue({}, schemas)
const wrapper = result.count as { value: { type: string, value: unknown } }
expect(wrapper.value.value).toBe(42)
})
})
describe('getPlainValue', () => {
it('unwraps { value: ... } structure to plain values', () => {
const input = {
a: { value: { type: 'constant', val: 1 } },
b: { value: { type: 'mixed', val: 'text' } },
}
const result = getPlainValue(input)
expect(result.a).toEqual({ type: 'constant', val: 1 })
expect(result.b).toEqual({ type: 'mixed', val: 'text' })
})
it('returns empty object for empty input', () => {
expect(getPlainValue({})).toEqual({})
})
})
describe('getStructureValue', () => {
it('wraps plain values into { value: ... } structure', () => {
const input = { a: 'hello', b: 42 }
const result = getStructureValue(input)
expect(result).toEqual({ a: { value: 'hello' }, b: { value: 42 } })
})
it('returns empty object for empty input', () => {
expect(getStructureValue({})).toEqual({})
})
})
describe('getConfiguredValue', () => {
it('fills defaults with correctInitialData for missing values', () => {
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
const result = getConfiguredValue({}, schemas)
const val = result.name as { type: string, value: unknown }
expect(val.type).toBe('mixed')
})
it('does not override existing values', () => {
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
const result = getConfiguredValue({ name: 'existing' }, schemas)
expect(result.name).toBe('existing')
})
it('escapes newlines in string defaults', () => {
const schemas = [{ variable: 'prompt', type: 'text-input', default: 'line1\nline2' }]
const result = getConfiguredValue({}, schemas)
const val = result.prompt as { type: string, value: unknown }
expect(val.type).toBe('mixed')
expect(val.value).toBe('line1\\nline2')
})
it('handles boolean default conversion', () => {
const schemas = [{ variable: 'flag', type: 'boolean', default: 'true' }]
const result = getConfiguredValue({}, schemas)
const val = result.flag as { type: string, value: unknown }
expect(val.value).toBe(true)
})
it('handles app-selector type', () => {
const schemas = [{ variable: 'app', type: 'app-selector', default: 'app-id-123' }]
const result = getConfiguredValue({}, schemas)
const val = result.app as { type: string, value: unknown }
expect(val.value).toBe('app-id-123')
})
})
describe('generateAgentToolValue', () => {
it('generates constant-type values in non-reasoning mode', () => {
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
const value = { name: { value: 'world' } }
const result = generateAgentToolValue(value, schemas)
expect(result.name.value).toBeDefined()
expect(result.name.value!.type).toBe('mixed')
})
it('generates auto:1 for auto-mode parameters in reasoning mode', () => {
const schemas = [{ variable: 'name', type: 'text-input' }]
const value = { name: { auto: 1 as const, value: undefined } }
const result = generateAgentToolValue(value, schemas, true)
expect(result.name).toEqual({ auto: 1, value: null })
})
it('generates auto:0 with value for manual parameters in reasoning mode', () => {
const schemas = [{ variable: 'name', type: 'text-input' }]
const value = { name: { auto: 0 as const, value: { type: 'constant', value: 'manual' } } }
const result = generateAgentToolValue(value, schemas, true)
expect(result.name.auto).toBe(0)
expect(result.name.value).toEqual({ type: 'constant', value: 'manual' })
})
it('handles undefined value in reasoning mode with fallback', () => {
const schemas = [{ variable: 'name', type: 'select' }]
const value = { name: { auto: 0 as const, value: undefined } }
const result = generateAgentToolValue(value, schemas, true)
expect(result.name.auto).toBe(0)
expect(result.name.value).toEqual({ type: 'constant', value: null })
})
it('applies correctInitialData for text-input type', () => {
const schemas = [{ variable: 'query', type: 'text-input' }]
const value = { query: { value: 'search term' } }
const result = generateAgentToolValue(value, schemas)
expect(result.query.value!.type).toBe('mixed')
})
it('applies correctInitialData for boolean type conversion', () => {
const schemas = [{ variable: 'flag', type: 'boolean' }]
const value = { flag: { value: 'true' } }
const result = generateAgentToolValue(value, schemas)
expect(result.flag.value!.value).toBe(true)
})
})
})

View File

@ -1,13 +1,13 @@
import type { WorkflowToolModalPayload } from './index'
import type { WorkflowToolModalPayload } from '../index'
import type { WorkflowToolProviderResponse } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { InputVarType, VarType } from '@/app/components/workflow/types'
import WorkflowToolConfigureButton from './configure-button'
import WorkflowToolAsModal from './index'
import MethodSelector from './method-selector'
import WorkflowToolConfigureButton from '../configure-button'
import WorkflowToolAsModal from '../index'
import MethodSelector from '../method-selector'
// Mock Next.js navigation
const mockPush = vi.fn()

View File

@ -2,7 +2,7 @@ import type { ComponentProps } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MethodSelector from './method-selector'
import MethodSelector from '../method-selector'
// Test utilities
const defaultProps: ComponentProps<typeof MethodSelector> = {

View File

@ -1,7 +1,7 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import ConfirmModal from './index'
import ConfirmModal from '../index'
// Test utilities
const defaultProps = {