mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 01:48:04 +08:00
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:
263
web/app/components/tools/__tests__/provider-list.spec.tsx
Normal file
263
web/app/components/tools/__tests__/provider-list.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 = {
|
||||
@ -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(),
|
||||
@ -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')
|
||||
@ -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(),
|
||||
@ -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 = [
|
||||
@ -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 = [
|
||||
41
web/app/components/tools/labels/__tests__/store.spec.ts
Normal file
41
web/app/components/tools/labels/__tests__/store.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
201
web/app/components/tools/marketplace/__tests__/hooks.spec.ts
Normal file
201
web/app/components/tools/marketplace/__tests__/hooks.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
180
web/app/components/tools/marketplace/__tests__/index.spec.tsx
Normal file
180
web/app/components/tools/marketplace/__tests__/index.spec.tsx
Normal 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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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 = {
|
||||
@ -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])
|
||||
@ -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', () => ({
|
||||
@ -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 = {
|
||||
@ -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,
|
||||
@ -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', () => ({
|
||||
@ -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
|
||||
@ -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>
|
||||
),
|
||||
@ -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', () => {
|
||||
@ -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 = {
|
||||
@ -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}
|
||||
@ -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 => ({
|
||||
@ -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', () => ({
|
||||
@ -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: {
|
||||
@ -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 = {
|
||||
@ -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 = {
|
||||
@ -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 = {
|
||||
@ -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
|
||||
713
web/app/components/tools/provider/__tests__/detail.spec.tsx
Normal file
713
web/app/components/tools/provider/__tests__/detail.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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', () => ({
|
||||
@ -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', () => ({
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
82
web/app/components/tools/utils/__tests__/index.spec.ts
Normal file
82
web/app/components/tools/utils/__tests__/index.spec.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
})
|
||||
408
web/app/components/tools/utils/__tests__/to-form-schema.spec.ts
Normal file
408
web/app/components/tools/utils/__tests__/to-form-schema.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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> = {
|
||||
@ -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 = {
|
||||
Reference in New Issue
Block a user