mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 17:27:16 -05:00
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Coding On Star <447357187@qq.com>
369 lines
11 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|