Files
dify/web/app/components/tools/marketplace/index.spec.tsx
Joel 89e4261883 chore: add some tests case code (#29927)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Coding On Star <447357187@qq.com>
2025-12-19 16:04:23 +08:00

369 lines
11 KiB
TypeScript

import React from 'react'
import { act, render, renderHook, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Marketplace from './index'
import { useMarketplace } from './hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
import type { Collection } from '@/app/components/tools/types'
import { CollectionType } from '@/app/components/tools/types'
import type { Plugin } from '@/app/components/plugins/types'
const listRenderSpy = jest.fn()
jest.mock('@/app/components/plugins/marketplace/list', () => ({
__esModule: true,
default: (props: {
marketplaceCollections: unknown[]
marketplaceCollectionPluginsMap: Record<string, unknown[]>
plugins?: unknown[]
showInstallButton?: boolean
locale: string
}) => {
listRenderSpy(props)
return <div data-testid="marketplace-list" />
},
}))
const mockUseMarketplaceCollectionsAndPlugins = jest.fn()
const mockUseMarketplacePlugins = jest.fn()
jest.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => mockUseMarketplaceCollectionsAndPlugins(...args),
useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args),
}))
const mockUseAllToolProviders = jest.fn()
jest.mock('@/service/use-tools', () => ({
useAllToolProviders: (...args: unknown[]) => mockUseAllToolProviders(...args),
}))
jest.mock('@/utils/var', () => ({
__esModule: true,
getMarketplaceUrl: jest.fn(() => 'https://marketplace.test/market'),
}))
jest.mock('@/i18n-config', () => ({
getLocaleOnClient: () => 'en',
}))
jest.mock('next-themes', () => ({
useTheme: () => ({ theme: 'light' }),
}))
const { getMarketplaceUrl: mockGetMarketplaceUrl } = jest.requireMock('@/utils/var') as {
getMarketplaceUrl: jest.Mock
}
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: jest.fn(),
page: 1,
...overrides,
})
describe('Marketplace', () => {
beforeEach(() => {
jest.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={jest.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={jest.fn()}
marketplaceContext={marketplaceContext}
/>,
)
// Assert
expect(screen.getByTestId('marketplace-list')).toBeInTheDocument()
expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({
showInstallButton: true,
locale: 'en',
}))
})
})
// 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 = jest.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 = jest.fn()
const mockQueryPlugins = jest.fn()
const mockQueryPluginsWithDebounced = jest.fn()
const mockResetPlugins = jest.fn()
const mockFetchNextPage = jest.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(() => {
jest.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()
})
})
})