This commit is contained in:
Stephen Zhou 2026-04-01 12:05:57 +08:00
parent 5bbd268fc1
commit b7549faacb
No known key found for this signature in database
18 changed files with 329 additions and 1332 deletions

View File

@ -75,11 +75,10 @@ vi.mock('@/app/components/plugins/card/base/description', () => ({
})) }))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({ vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => ( default: ({ orgName, downloadCount }: { orgName: string, downloadCount?: number }) => (
<div data-testid="org-info"> <div data-testid="org-info">
{orgName} {orgName}
/ {typeof downloadCount === 'number' ? ` · ${downloadCount}` : null}
{packageName}
</div> </div>
), ),
})) }))
@ -124,7 +123,7 @@ describe('Plugin Card Rendering Integration', () => {
expect(screen.getByTestId('card-icon')).toBeInTheDocument() expect(screen.getByTestId('card-icon')).toBeInTheDocument()
expect(screen.getByTestId('title')).toHaveTextContent('Google Search') expect(screen.getByTestId('title')).toHaveTextContent('Google Search')
expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius/google-search') expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius')
expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google') expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google')
}) })

View File

@ -181,7 +181,7 @@ describe('Card', () => {
render(<Card payload={plugin} />) render(<Card payload={plugin} />)
expect(screen.getByText('my-org')).toBeInTheDocument() expect(screen.getByText('my-org')).toBeInTheDocument()
expect(screen.getByText('my-plugin')).toBeInTheDocument() expect(screen.queryByText('my-plugin')).not.toBeInTheDocument()
}) })
it('should render plugin icon', () => { it('should render plugin icon', () => {
@ -596,7 +596,7 @@ describe('Card', () => {
render(<Card payload={plugin} />) render(<Card payload={plugin} />)
expect(screen.getByText('plugin-with-special-chars!@#$%')).toBeInTheDocument() expect(screen.getByText('org<script>alert(1)</script>')).toBeInTheDocument()
}) })
it('should handle very long title', () => { it('should handle very long title', () => {

View File

@ -2,6 +2,12 @@ import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import DownloadCount from '../download-count' import DownloadCount from '../download-count'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string) => key === 'marketplace.installs' ? 'installs' : key,
}),
}))
vi.mock('@/utils/format', () => ({ vi.mock('@/utils/format', () => ({
formatNumber: (n: number) => { formatNumber: (n: number) => {
if (n >= 1000) if (n >= 1000)
@ -13,16 +19,16 @@ vi.mock('@/utils/format', () => ({
describe('DownloadCount', () => { describe('DownloadCount', () => {
it('renders formatted download count', () => { it('renders formatted download count', () => {
render(<DownloadCount downloadCount={1500} />) render(<DownloadCount downloadCount={1500} />)
expect(screen.getByText('1.5k')).toBeInTheDocument() expect(screen.getByText('1.5k installs')).toBeInTheDocument()
}) })
it('renders small numbers directly', () => { it('renders small numbers directly', () => {
render(<DownloadCount downloadCount={42} />) render(<DownloadCount downloadCount={42} />)
expect(screen.getByText('42')).toBeInTheDocument() expect(screen.getByText('42 installs')).toBeInTheDocument()
}) })
it('renders zero download count', () => { it('renders zero download count', () => {
render(<DownloadCount downloadCount={0} />) render(<DownloadCount downloadCount={0} />)
expect(screen.getByText('0')).toBeInTheDocument() expect(screen.getByText('0 installs')).toBeInTheDocument()
}) })
}) })

View File

@ -15,6 +15,22 @@ import {
} from '../atoms' } from '../atoms'
import { DEFAULT_PLUGIN_SORT } from '../constants' import { DEFAULT_PLUGIN_SORT } from '../constants'
const { mockRouterPush, mockNavigation } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockNavigation: {
pathname: '/plugins',
params: {} as Record<string, string | undefined>,
},
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
usePathname: () => mockNavigation.pathname,
useParams: () => mockNavigation.params,
}))
const createWrapper = (searchParams = '') => { const createWrapper = (searchParams = '') => {
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams }) const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams })
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper = ({ children }: { children: ReactNode }) => (
@ -30,6 +46,8 @@ const createWrapper = (searchParams = '') => {
describe('Marketplace sort atoms', () => { describe('Marketplace sort atoms', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
}) })
it('should return default sort value from useMarketplaceSort', () => { it('should return default sort value from useMarketplaceSort', () => {
@ -76,6 +94,8 @@ describe('Marketplace sort atoms', () => {
describe('useSearchText', () => { describe('useSearchText', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
}) })
it('should return empty string as default', () => { it('should return empty string as default', () => {
@ -108,6 +128,8 @@ describe('useSearchText', () => {
describe('useActivePluginCategory', () => { describe('useActivePluginCategory', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
}) })
it('should return "all" as default category', () => { it('should return "all" as default category', () => {
@ -128,6 +150,8 @@ describe('useActivePluginCategory', () => {
describe('useFilterPluginTags', () => { describe('useFilterPluginTags', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
}) })
it('should return empty array as default', () => { it('should return empty array as default', () => {
@ -148,6 +172,8 @@ describe('useFilterPluginTags', () => {
describe('useMarketplaceSearchMode', () => { describe('useMarketplaceSearchMode', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
}) })
it('should return false when no search text, no tags, and category has collections (all)', () => { it('should return false when no search text, no tags, and category has collections (all)', () => {
@ -161,7 +187,7 @@ describe('useMarketplaceSearchMode', () => {
const { wrapper } = createWrapper('?q=test&category=all') const { wrapper } = createWrapper('?q=test&category=all')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper }) const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
expect(result.current).toBe(true) expect(result.current).toBeTruthy()
}) })
it('should return true when tags are present', () => { it('should return true when tags are present', () => {
@ -189,6 +215,8 @@ describe('useMarketplaceSearchMode', () => {
describe('useMarketplaceMoreClick', () => { describe('useMarketplaceMoreClick', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
}) })
it('should return a callback function', () => { it('should return a callback function', () => {

View File

@ -42,8 +42,10 @@ const mockCollectionPlugins = vi.fn()
vi.mock('@/service/client', () => ({ vi.mock('@/service/client', () => ({
marketplaceClient: { marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args), plugins: {
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args), collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
},
}, },
})) }))

View File

@ -2,6 +2,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/headers', () => ({
headers: async () => ({
get: (name: string) => name === 'sec-fetch-dest' ? 'document' : null,
}),
}))
vi.mock('@/config', () => ({ vi.mock('@/config', () => ({
API_PREFIX: '/api', API_PREFIX: '/api',
APP_VERSION: '1.0.0', APP_VERSION: '1.0.0',
@ -15,15 +21,24 @@ vi.mock('@/utils/var', () => ({
const mockCollections = vi.fn() const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn() const mockCollectionPlugins = vi.fn()
const mockSearchAdvanced = vi.fn()
vi.mock('@/service/client', () => ({ vi.mock('@/service/client', () => ({
marketplaceClient: { marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args), plugins: {
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args), collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
}, },
marketplaceQuery: { marketplaceQuery: {
collections: { plugins: {
queryKey: (params: unknown) => ['marketplace', 'collections', params], collections: {
queryKey: (params: unknown) => ['marketplace', 'plugins', 'collections', params],
},
searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'plugins', 'searchAdvanced', params],
},
}, },
}, },
})) }))
@ -46,6 +61,9 @@ describe('HydrateQueryClient', () => {
mockCollectionPlugins.mockResolvedValue({ mockCollectionPlugins.mockResolvedValue({
data: { plugins: [] }, data: { plugins: [] },
}) })
mockSearchAdvanced.mockResolvedValue({
data: { plugins: [], total: 0 },
})
}) })
it('should render children within HydrationBoundary', async () => { it('should render children within HydrationBoundary', async () => {
@ -81,6 +99,7 @@ describe('HydrateQueryClient', () => {
await HydrateQueryClient({ await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'all' }), searchParams: Promise.resolve({ category: 'all' }),
isMarketplacePlatform: true,
children: <div>Child</div>, children: <div>Child</div>,
}) })
@ -92,31 +111,36 @@ describe('HydrateQueryClient', () => {
await HydrateQueryClient({ await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'tool' }), searchParams: Promise.resolve({ category: 'tool' }),
isMarketplacePlatform: true,
children: <div>Child</div>, children: <div>Child</div>,
}) })
expect(mockCollections).toHaveBeenCalled() expect(mockCollections).toHaveBeenCalled()
}) })
it('should not prefetch when category does not have collections (model)', async () => { it('should prefetch search results when category does not have collections (model)', async () => {
const { HydrateQueryClient } = await import('../hydration-server') const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({ await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'model' }), searchParams: Promise.resolve({ category: 'model' }),
isMarketplacePlatform: true,
children: <div>Child</div>, children: <div>Child</div>,
}) })
expect(mockCollections).not.toHaveBeenCalled() expect(mockCollections).toHaveBeenCalled()
expect(mockSearchAdvanced).toHaveBeenCalled()
}) })
it('should not prefetch when category does not have collections (bundle)', async () => { it('should prefetch search results when category does not have collections (bundle)', async () => {
const { HydrateQueryClient } = await import('../hydration-server') const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({ await HydrateQueryClient({
searchParams: Promise.resolve({ category: 'bundle' }), searchParams: Promise.resolve({ category: 'bundle' }),
isMarketplacePlatform: true,
children: <div>Child</div>, children: <div>Child</div>,
}) })
expect(mockCollections).not.toHaveBeenCalled() expect(mockCollections).toHaveBeenCalled()
expect(mockSearchAdvanced).toHaveBeenCalled()
}) })
}) })

View File

@ -5,6 +5,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNuqsTestWrapper } from '@/test/nuqs-testing' import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { PluginCategorySwitch } from '../category-switch/plugin' import { PluginCategorySwitch } from '../category-switch/plugin'
const { mockRouterPush, mockNavigation } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockNavigation: {
pathname: '/plugins',
params: {} as Record<string, string | undefined>,
},
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
usePathname: () => mockNavigation.pathname,
useParams: () => mockNavigation.params,
}))
vi.mock('#i18n', () => ({ vi.mock('#i18n', () => ({
useTranslation: () => ({ useTranslation: () => ({
t: (key: string) => { t: (key: string) => {
@ -38,6 +54,8 @@ const createWrapper = (searchParams = '') => {
describe('PluginCategorySwitch', () => { describe('PluginCategorySwitch', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
}) })
it('should render all category options', () => { it('should render all category options', () => {

View File

@ -20,16 +20,20 @@ const mockSearchAdvanced = vi.fn()
vi.mock('@/service/client', () => ({ vi.mock('@/service/client', () => ({
marketplaceClient: { marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args), plugins: {
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args), collections: (...args: unknown[]) => mockCollections(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args), collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
}, },
marketplaceQuery: { marketplaceQuery: {
collections: { plugins: {
queryKey: (params: unknown) => ['marketplace', 'collections', params], collections: {
}, queryKey: (params: unknown) => ['marketplace', 'plugins', 'collections', params],
searchAdvanced: { },
queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params], searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'plugins', 'searchAdvanced', params],
},
}, },
}, },
})) }))

View File

@ -5,6 +5,10 @@ import { Provider as JotaiProvider } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNuqsTestWrapper } from '@/test/nuqs-testing' import { createNuqsTestWrapper } from '@/test/nuqs-testing'
vi.mock('ahooks', () => ({
useDebounce: <T,>(value: T) => value,
}))
vi.mock('@/config', () => ({ vi.mock('@/config', () => ({
API_PREFIX: '/api', API_PREFIX: '/api',
APP_VERSION: '1.0.0', APP_VERSION: '1.0.0',
@ -19,19 +23,38 @@ vi.mock('@/utils/var', () => ({
const mockCollections = vi.fn() const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn() const mockCollectionPlugins = vi.fn()
const mockSearchAdvanced = vi.fn() const mockSearchAdvanced = vi.fn()
const { mockRouterPush, mockNavigation } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockNavigation: {
pathname: '/plugins',
params: {} as Record<string, string | undefined>,
},
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
usePathname: () => mockNavigation.pathname,
useParams: () => mockNavigation.params,
}))
vi.mock('@/service/client', () => ({ vi.mock('@/service/client', () => ({
marketplaceClient: { marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args), plugins: {
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args), collections: (...args: unknown[]) => mockCollections(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args), collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
}, },
marketplaceQuery: { marketplaceQuery: {
collections: { plugins: {
queryKey: (params: unknown) => ['marketplace', 'collections', params], collections: {
}, queryKey: (params: unknown) => ['marketplace', 'plugins', 'collections', params],
searchAdvanced: { },
queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params], searchAdvanced: {
queryKey: (params: unknown) => ['marketplace', 'plugins', 'searchAdvanced', params],
},
}, },
}, },
})) }))
@ -58,6 +81,8 @@ const createWrapper = (searchParams = '') => {
describe('usePluginsMarketplaceData', () => { describe('usePluginsMarketplaceData', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockNavigation.pathname = '/plugins'
mockNavigation.params = {}
mockCollections.mockResolvedValue({ mockCollections.mockResolvedValue({
data: { data: {

View File

@ -109,7 +109,7 @@ describe('getPluginLinkInMarketplace', () => {
const { getPluginLinkInMarketplace } = await import('../utils') const { getPluginLinkInMarketplace } = await import('../utils')
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' }) const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
const link = getPluginLinkInMarketplace(plugin) const link = getPluginLinkInMarketplace(plugin)
expect(link).toBe('https://marketplace.dify.ai/plugins/test-org/test-plugin') expect(link).toBe('https://marketplace.dify.ai/plugin/test-org/test-plugin')
}) })
it('should return correct link for bundle', async () => { it('should return correct link for bundle', async () => {
@ -125,7 +125,7 @@ describe('getPluginDetailLinkInMarketplace', () => {
const { getPluginDetailLinkInMarketplace } = await import('../utils') const { getPluginDetailLinkInMarketplace } = await import('../utils')
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' }) const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
const link = getPluginDetailLinkInMarketplace(plugin) const link = getPluginDetailLinkInMarketplace(plugin)
expect(link).toBe('/plugins/test-org/test-plugin') expect(link).toBe('/plugin/test-org/test-plugin')
}) })
it('should return correct detail link for bundle', async () => { it('should return correct detail link for bundle', async () => {
@ -149,7 +149,7 @@ describe('getPluginCondition', () => {
it('should return category condition for agent', async () => { it('should return category condition for agent', async () => {
const { getPluginCondition } = await import('../utils') const { getPluginCondition } = await import('../utils')
expect(getPluginCondition(PluginCategoryEnum.agent)).toBe('category=agent') expect(getPluginCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
}) })
it('should return category condition for datasource', async () => { it('should return category condition for datasource', async () => {

View File

@ -2,648 +2,100 @@ import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Description } from '../index' import { Description } from '../index'
// ================================
// Mock external dependencies
// ================================
// Track mock locale for testing
let mockDefaultLocale = 'en-US'
// Mock translations with realistic values
const pluginTranslations: Record<string, string> = {
'marketplace.empower': 'Empower your AI development',
'marketplace.discover': 'Discover',
'marketplace.difyMarketplace': 'Dify Marketplace',
'marketplace.and': 'and',
'category.models': 'Models',
'category.tools': 'Tools',
'category.datasources': 'Data Sources',
'category.triggers': 'Triggers',
'category.agents': 'Agent Strategies',
'category.extensions': 'Extensions',
'category.bundles': 'Bundles',
}
const commonTranslations: Record<string, string> = {
'operation.in': 'in',
}
// Mock i18n hooks
vi.mock('#i18n', () => ({ vi.mock('#i18n', () => ({
useLocale: vi.fn(() => mockDefaultLocale), useTranslation: () => ({
useTranslation: vi.fn((ns: string) => ({
t: (key: string) => { t: (key: string) => {
if (ns === 'plugin') const translations: Record<string, string> = {
return pluginTranslations[key] || key 'marketplace.pluginsHeroTitle': 'Build with plugins',
if (ns === 'common') 'marketplace.pluginsHeroSubtitle': 'Discover and install marketplace plugins.',
return commonTranslations[key] || key 'marketplace.templatesHeroTitle': 'Build with templates',
return key 'marketplace.templatesHeroSubtitle': 'Explore reusable templates.',
}
return translations[key] || key
}, },
})), }),
})) }))
// ================================ let mockCreationType = 'plugins'
// Description Component Tests
// ================================ vi.mock('../../atoms', () => ({
useCreationType: () => mockCreationType,
}))
vi.mock('../../search-params', () => ({
CREATION_TYPE: {
plugins: 'plugins',
templates: 'templates',
},
}))
vi.mock('../../category-switch', () => ({
PluginCategorySwitch: ({ variant }: { variant?: string }) => <div data-testid="plugin-category-switch">{variant}</div>,
TemplateCategorySwitch: ({ variant }: { variant?: string }) => <div data-testid="template-category-switch">{variant}</div>,
}))
vi.mock('motion/react', () => ({
motion: {
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
},
useMotionValue: (value: number) => ({ set: vi.fn(), get: () => value }),
useSpring: (value: unknown) => value,
useTransform: (...args: unknown[]) => {
const values = args[0]
if (Array.isArray(values))
return 0
return values
},
}))
class ResizeObserverMock {
observe() {}
disconnect() {}
}
describe('Description', () => { describe('Description', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockDefaultLocale = 'en-US' mockCreationType = 'plugins'
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 1
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
}) })
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => { describe('Rendering', () => {
it('should render without crashing', () => { it('should render plugin hero content by default', () => {
const { container } = render(<Description />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render h1 heading with empower text', () => {
render(<Description />) render(<Description />)
const heading = screen.getByRole('heading', { level: 1 }) expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Build with plugins')
expect(heading).toBeInTheDocument() expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Discover and install marketplace plugins.')
expect(heading).toHaveTextContent('Empower your AI development') expect(screen.getByTestId('plugin-category-switch')).toHaveTextContent('hero')
}) })
it('should render h2 subheading', () => { it('should render template hero content when creationType is templates', () => {
mockCreationType = 'templates'
render(<Description />) render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 }) expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Build with templates')
expect(subheading).toBeInTheDocument() expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Explore reusable templates.')
}) expect(screen.getByTestId('template-category-switch')).toHaveTextContent('hero')
it('should apply correct CSS classes to h1', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toHaveClass('title-4xl-semi-bold')
expect(heading).toHaveClass('mb-2')
expect(heading).toHaveClass('text-center')
expect(heading).toHaveClass('text-text-primary')
})
it('should apply correct CSS classes to h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('body-md-regular')
expect(subheading).toHaveClass('text-center')
expect(subheading).toHaveClass('text-text-tertiary')
}) })
}) })
// ================================ describe('Props', () => {
// Non-Chinese Locale Rendering Tests it('should render marketplace nav content when provided', () => {
// ================================ render(<Description marketplaceNav={<div data-testid="marketplace-nav">Nav</div>} />)
describe('Non-Chinese Locale Rendering', () => {
beforeEach(() => { expect(screen.getByTestId('marketplace-nav')).toBeInTheDocument()
mockDefaultLocale = 'en-US'
}) })
it('should render discover text for en-US locale', () => { it('should apply custom className to the sticky wrapper', () => {
render(<Description />) const { container } = render(<Description className="custom-hero-class" />)
expect(screen.getByText(/Discover/)).toBeInTheDocument() expect(container.querySelector('.custom-hero-class')).toBeInTheDocument()
})
it('should render all category names', () => {
render(<Description />)
expect(screen.getByText('Models')).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
expect(screen.getByText('Data Sources')).toBeInTheDocument()
expect(screen.getByText('Triggers')).toBeInTheDocument()
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
expect(screen.getByText('Extensions')).toBeInTheDocument()
expect(screen.getByText('Bundles')).toBeInTheDocument()
})
it('should render "and" conjunction text', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('and')
})
it('should render "in" preposition at the end for non-Chinese locales', () => {
render(<Description />)
expect(screen.getByText('in')).toBeInTheDocument()
})
it('should render Dify Marketplace text at the end for non-Chinese locales', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should render category spans with styled underline effect', () => {
const { container } = render(<Description />)
const styledSpans = container.querySelectorAll('.body-md-medium.relative.z-\\[1\\]')
// 7 category spans (models, tools, datasources, triggers, agents, extensions, bundles)
expect(styledSpans.length).toBe(7)
})
it('should apply text-text-secondary class to category spans', () => {
const { container } = render(<Description />)
const styledSpans = container.querySelectorAll('.text-text-secondary')
expect(styledSpans.length).toBeGreaterThanOrEqual(7)
})
})
// ================================
// Chinese (zh-Hans) Locale Rendering Tests
// ================================
describe('Chinese (zh-Hans) Locale Rendering', () => {
beforeEach(() => {
mockDefaultLocale = 'zh-Hans'
})
it('should render "in" text at the beginning for zh-Hans locale', () => {
render(<Description />)
// In zh-Hans mode, "in" appears at the beginning
const inElements = screen.getAllByText('in')
expect(inElements.length).toBeGreaterThanOrEqual(1)
})
it('should render Dify Marketplace text for zh-Hans locale', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should render discover text for zh-Hans locale', () => {
render(<Description />)
expect(screen.getByText(/Discover/)).toBeInTheDocument()
})
it('should render all categories for zh-Hans locale', () => {
render(<Description />)
expect(screen.getByText('Models')).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
expect(screen.getByText('Data Sources')).toBeInTheDocument()
expect(screen.getByText('Triggers')).toBeInTheDocument()
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
expect(screen.getByText('Extensions')).toBeInTheDocument()
expect(screen.getByText('Bundles')).toBeInTheDocument()
})
it('should render both zh-Hans specific elements and shared elements', () => {
render(<Description />)
// zh-Hans has specific element order: "in" -> Dify Marketplace -> Discover
// then the same category list with "and" -> Bundles
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('and')
})
})
// ================================
// Locale Variations Tests
// ================================
describe('Locale Variations', () => {
it('should use en-US locale by default', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle ja-JP locale as non-Chinese', () => {
mockDefaultLocale = 'ja-JP'
render(<Description />)
// Should render in non-Chinese format (discover first, then "in Dify Marketplace" at end)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should handle ko-KR locale as non-Chinese', () => {
mockDefaultLocale = 'ko-KR'
render(<Description />)
// Should render in non-Chinese format
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle de-DE locale as non-Chinese', () => {
mockDefaultLocale = 'de-DE'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle fr-FR locale as non-Chinese', () => {
mockDefaultLocale = 'fr-FR'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle pt-BR locale as non-Chinese', () => {
mockDefaultLocale = 'pt-BR'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle es-ES locale as non-Chinese', () => {
mockDefaultLocale = 'es-ES'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
})
// ================================
// Conditional Rendering Tests
// ================================
describe('Conditional Rendering', () => {
it('should render zh-Hans specific content when locale is zh-Hans', () => {
mockDefaultLocale = 'zh-Hans'
const { container } = render(<Description />)
// zh-Hans has additional span with mr-1 before "in" text at the start
const mrSpan = container.querySelector('span.mr-1')
expect(mrSpan).toBeInTheDocument()
})
it('should render non-Chinese specific content when locale is not zh-Hans', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
// Non-Chinese has "in" and "Dify Marketplace" at the end
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should not render zh-Hans intro content for non-Chinese locales', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
// For en-US, the order should be Discover ... in Dify Marketplace
// The "in" text should only appear once at the end
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// "in" should appear after "Bundles" and before "Dify Marketplace"
const bundlesIndex = content.indexOf('Bundles')
const inIndex = content.indexOf('in')
const marketplaceIndex = content.indexOf('Dify Marketplace')
expect(bundlesIndex).toBeLessThan(inIndex)
expect(inIndex).toBeLessThan(marketplaceIndex)
})
it('should render zh-Hans with proper word order', () => {
mockDefaultLocale = 'zh-Hans'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories
const inIndex = content.indexOf('in')
const marketplaceIndex = content.indexOf('Dify Marketplace')
const discoverIndex = content.indexOf('Discover')
expect(inIndex).toBeLessThan(marketplaceIndex)
expect(marketplaceIndex).toBeLessThan(discoverIndex)
})
})
// ================================
// Category Styling Tests
// ================================
describe('Category Styling', () => {
it('should apply underline effect with after pseudo-element styling', () => {
const { container } = render(<Description />)
const categorySpan = container.querySelector('.after\\:absolute')
expect(categorySpan).toBeInTheDocument()
})
it('should apply correct after pseudo-element classes', () => {
const { container } = render(<Description />)
// Check for the specific after pseudo-element classes
const categorySpans = container.querySelectorAll('.after\\:bottom-\\[1\\.5px\\]')
expect(categorySpans.length).toBe(7)
})
it('should apply full width to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:w-full')
expect(categorySpans.length).toBe(7)
})
it('should apply correct height to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:h-2')
expect(categorySpans.length).toBe(7)
})
it('should apply bg-text-text-selected to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:bg-text-text-selected')
expect(categorySpans.length).toBe(7)
})
it('should have z-index 1 on category spans', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.z-\\[1\\]')
expect(categorySpans.length).toBe(7)
})
it('should apply left margin to category spans', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.ml-1')
expect(categorySpans.length).toBeGreaterThanOrEqual(7)
})
it('should apply both left and right margin to specific spans', () => {
const { container } = render(<Description />)
// Extensions and Bundles spans have both ml-1 and mr-1
const extensionsBundlesSpans = container.querySelectorAll('.ml-1.mr-1')
expect(extensionsBundlesSpans.length).toBe(2)
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should render fragment as root element', () => {
const { container } = render(<Description />)
// Fragment renders h1 and h2 as direct children
expect(container.querySelector('h1')).toBeInTheDocument()
expect(container.querySelector('h2')).toBeInTheDocument()
})
it('should handle zh-Hant as non-Chinese simplified', () => {
mockDefaultLocale = 'zh-Hant'
render(<Description />)
// zh-Hant is different from zh-Hans, should use non-Chinese format
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// Check that "Dify Marketplace" appears at the end (non-Chinese format)
const discoverIndex = content.indexOf('Discover')
const marketplaceIndex = content.indexOf('Dify Marketplace')
// For non-Chinese locales, Discover should come before Dify Marketplace
expect(discoverIndex).toBeLessThan(marketplaceIndex)
})
})
// ================================
// Content Structure Tests
// ================================
describe('Content Structure', () => {
it('should have comma separators between categories', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// Commas should exist between categories
expect(content).toMatch(/Models[^\n\r,\u2028\u2029]*,.*Tools[^\n\r,\u2028\u2029]*,.*Data Sources[^\n\r,\u2028\u2029]*,.*Triggers[^\n\r,\u2028\u2029]*,.*Agent Strategies[^\n\r,\u2028\u2029]*,.*Extensions/)
})
it('should have "and" before last category (Bundles)', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// "and" should appear before Bundles
const andIndex = content.indexOf('and')
const bundlesIndex = content.indexOf('Bundles')
expect(andIndex).toBeLessThan(bundlesIndex)
})
it('should render all text elements in correct order for en-US', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
const expectedOrder = [
'Discover',
'Models',
'Tools',
'Data Sources',
'Triggers',
'Agent Strategies',
'Extensions',
'and',
'Bundles',
'in',
'Dify Marketplace',
]
let lastIndex = -1
for (const text of expectedOrder) {
const currentIndex = content.indexOf(text)
expect(currentIndex).toBeGreaterThan(lastIndex)
lastIndex = currentIndex
}
})
it('should render all text elements in correct order for zh-Hans', () => {
mockDefaultLocale = 'zh-Hans'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories -> and -> Bundles
const inIndex = content.indexOf('in')
const marketplaceIndex = content.indexOf('Dify Marketplace')
const discoverIndex = content.indexOf('Discover')
const modelsIndex = content.indexOf('Models')
expect(inIndex).toBeLessThan(marketplaceIndex)
expect(marketplaceIndex).toBeLessThan(discoverIndex)
expect(discoverIndex).toBeLessThan(modelsIndex)
})
})
// ================================
// Layout Tests
// ================================
describe('Layout', () => {
it('should have shrink-0 on h1 heading', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toHaveClass('shrink-0')
})
it('should have shrink-0 on h2 subheading', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('shrink-0')
})
it('should have flex layout on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('flex')
})
it('should have items-center on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('items-center')
})
it('should have justify-center on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('justify-center')
})
})
// ================================
// Accessibility Tests
// ================================
describe('Accessibility', () => {
it('should have proper heading hierarchy', () => {
render(<Description />)
const h1 = screen.getByRole('heading', { level: 1 })
const h2 = screen.getByRole('heading', { level: 2 })
expect(h1).toBeInTheDocument()
expect(h2).toBeInTheDocument()
})
it('should have readable text content', () => {
render(<Description />)
const h1 = screen.getByRole('heading', { level: 1 })
expect(h1.textContent).not.toBe('')
})
it('should have visible h1 heading', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toBeVisible()
})
it('should have visible h2 heading', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toBeVisible()
}) })
}) })
}) })
// ================================
// Integration Tests
// ================================
describe('Description Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDefaultLocale = 'en-US'
})
it('should render complete component structure', () => {
const { container } = render(<Description />)
// Main headings
expect(container.querySelector('h1')).toBeInTheDocument()
expect(container.querySelector('h2')).toBeInTheDocument()
// All category spans
const categorySpans = container.querySelectorAll('.body-md-medium')
expect(categorySpans.length).toBe(7)
})
it('should render complete zh-Hans structure', () => {
mockDefaultLocale = 'zh-Hans'
const { container } = render(<Description />)
// Main headings
expect(container.querySelector('h1')).toBeInTheDocument()
expect(container.querySelector('h2')).toBeInTheDocument()
// All category spans
const categorySpans = container.querySelectorAll('.body-md-medium')
expect(categorySpans.length).toBe(7)
})
it('should correctly differentiate between zh-Hans and en-US layouts', () => {
// Render en-US
mockDefaultLocale = 'en-US'
const { container: enContainer, unmount: unmountEn } = render(<Description />)
const enContent = enContainer.querySelector('h2')?.textContent || ''
unmountEn()
// Render zh-Hans
mockDefaultLocale = 'zh-Hans'
const { container: zhContainer } = render(<Description />)
const zhContent = zhContainer.querySelector('h2')?.textContent || ''
// Both should have all categories
expect(enContent).toContain('Models')
expect(zhContent).toContain('Models')
// But order should differ
const enMarketplaceIndex = enContent.indexOf('Dify Marketplace')
const enDiscoverIndex = enContent.indexOf('Discover')
const zhMarketplaceIndex = zhContent.indexOf('Dify Marketplace')
const zhDiscoverIndex = zhContent.indexOf('Discover')
// en-US: Discover comes before Dify Marketplace
expect(enDiscoverIndex).toBeLessThan(enMarketplaceIndex)
// zh-Hans: Dify Marketplace comes before Discover
expect(zhMarketplaceIndex).toBeLessThan(zhDiscoverIndex)
})
it('should maintain consistent styling across locales', () => {
// Render en-US
mockDefaultLocale = 'en-US'
const { container: enContainer, unmount: unmountEn } = render(<Description />)
const enCategoryCount = enContainer.querySelectorAll('.body-md-medium').length
unmountEn()
// Render zh-Hans
mockDefaultLocale = 'zh-Hans'
const { container: zhContainer } = render(<Description />)
const zhCategoryCount = zhContainer.querySelectorAll('.body-md-medium').length
// Both should have same number of styled category spans
expect(enCategoryCount).toBe(zhCategoryCount)
expect(enCategoryCount).toBe(7)
})
})

View File

@ -43,6 +43,7 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
mockMoreClick: vi.fn(), mockMoreClick: vi.fn(),
} }
}) })
let mockSearchMode = false
vi.mock('../../state', () => ({ vi.mock('../../state', () => ({
useMarketplaceData: () => mockMarketplaceData, useMarketplaceData: () => mockMarketplaceData,
@ -51,7 +52,11 @@ vi.mock('../../state', () => ({
vi.mock('../../atoms', () => ({ vi.mock('../../atoms', () => ({
useMarketplaceMoreClick: () => mockMoreClick, useMarketplaceMoreClick: () => mockMoreClick,
useMarketplaceSearchMode: () => false, useMarketplaceSearchMode: () => mockSearchMode,
useCreationType: () => 'plugins',
useFilterPluginTags: () => [[]],
useActivePluginCategory: () => ['all'],
useActiveTemplateCategory: () => ['all'],
})) }))
vi.mock('@/context/i18n', () => ({ vi.mock('@/context/i18n', () => ({
@ -148,7 +153,7 @@ vi.mock('../../sort-dropdown', () => ({
})) }))
// Mock Empty component // Mock Empty component
vi.mock('../empty', () => ({ vi.mock('../../empty', () => ({
default: ({ className, text }: { className?: string, text?: string }) => ( default: ({ className, text }: { className?: string, text?: string }) => (
<div data-testid="empty-component" className={className}> <div data-testid="empty-component" className={className}>
{text || 'No plugins found'} {text || 'No plugins found'}
@ -234,6 +239,7 @@ describe('List', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockSearchMode = false
}) })
// ================================ // ================================
@ -872,6 +878,7 @@ describe('ListWithCollection', () => {
describe('ListWrapper', () => { describe('ListWrapper', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockSearchMode = false
// Reset mock data // Reset mock data
mockMarketplaceData.plugins = undefined mockMarketplaceData.plugins = undefined
mockMarketplaceData.pluginsTotal = 0 mockMarketplaceData.pluginsTotal = 0
@ -917,6 +924,7 @@ describe('ListWrapper', () => {
}) })
it('should render template empty state with flex content wrapper when templates are empty', () => { it('should render template empty state with flex content wrapper when templates are empty', () => {
mockSearchMode = true
delete (mockMarketplaceData as Record<string, unknown>).pluginCollections delete (mockMarketplaceData as Record<string, unknown>).pluginCollections
delete (mockMarketplaceData as Record<string, unknown>).pluginCollectionPluginsMap delete (mockMarketplaceData as Record<string, unknown>).pluginCollectionPluginsMap
;(mockMarketplaceData as Record<string, unknown>).templateCollections = [] ;(mockMarketplaceData as Record<string, unknown>).templateCollections = []
@ -931,6 +939,7 @@ describe('ListWrapper', () => {
}) })
it('should keep plugin empty text when plugins are empty', () => { it('should keep plugin empty text when plugins are empty', () => {
mockSearchMode = true
mockMarketplaceData.plugins = [] mockMarketplaceData.plugins = []
mockMarketplaceData.pluginsTotal = 0 mockMarketplaceData.pluginsTotal = 0
mockMarketplaceData.pluginCollections = [] mockMarketplaceData.pluginCollections = []
@ -947,16 +956,18 @@ describe('ListWrapper', () => {
// Plugins Header Tests // Plugins Header Tests
// ================================ // ================================
describe('Plugins Header', () => { describe('Plugins Header', () => {
it('should render plugins result count when plugins are present', () => { it('should render list top info when search mode is enabled', () => {
mockSearchMode = true
mockMarketplaceData.plugins = createMockPluginList(5) mockMarketplaceData.plugins = createMockPluginList(5)
mockMarketplaceData.pluginsTotal = 5 mockMarketplaceData.pluginsTotal = 5
render(<ListWrapper />) render(<ListWrapper />)
expect(screen.getByText('5 plugins found')).toBeInTheDocument() expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument()
}) })
it('should render SortDropdown when plugins are present', () => { it('should render SortDropdown when plugins are present', () => {
mockSearchMode = true
mockMarketplaceData.plugins = createMockPluginList(1) mockMarketplaceData.plugins = createMockPluginList(1)
render(<ListWrapper />) render(<ListWrapper />)
@ -1039,25 +1050,28 @@ describe('ListWrapper', () => {
// ================================ // ================================
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should handle empty plugins array', () => { it('should handle empty plugins array', () => {
mockSearchMode = true
mockMarketplaceData.plugins = [] mockMarketplaceData.plugins = []
mockMarketplaceData.pluginsTotal = 0 mockMarketplaceData.pluginsTotal = 0
render(<ListWrapper />) render(<ListWrapper />)
expect(screen.getByText('0 plugins found')).toBeInTheDocument()
expect(screen.getByTestId('empty-component')).toBeInTheDocument() expect(screen.getByTestId('empty-component')).toBeInTheDocument()
}) })
it('should handle large pluginsTotal', () => { it('should handle many plugin results', () => {
mockSearchMode = true
mockMarketplaceData.plugins = createMockPluginList(10) mockMarketplaceData.plugins = createMockPluginList(10)
mockMarketplaceData.pluginsTotal = 10000 mockMarketplaceData.pluginsTotal = 10000
render(<ListWrapper />) render(<ListWrapper />)
expect(screen.getByText('10000 plugins found')).toBeInTheDocument() expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
expect(screen.getByTestId('card-plugin-9')).toBeInTheDocument()
}) })
it('should handle both loading and has plugins', () => { it('should handle both loading and has plugins', () => {
mockSearchMode = true
mockMarketplaceData.isLoading = true mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 2 mockMarketplaceData.page = 2
mockMarketplaceData.plugins = createMockPluginList(5) mockMarketplaceData.plugins = createMockPluginList(5)
@ -1065,9 +1079,7 @@ describe('ListWrapper', () => {
render(<ListWrapper />) render(<ListWrapper />)
// Should show plugins header and list expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
expect(screen.getByText('50 plugins found')).toBeInTheDocument()
// Should not show loading because page > 1
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
}) })
}) })
@ -1405,6 +1417,7 @@ describe('Combined Workflows', () => {
}) })
it('should transition from collections to search results', async () => { it('should transition from collections to search results', async () => {
mockSearchMode = true
mockMarketplaceData.pluginCollections = createMockCollectionList(1) mockMarketplaceData.pluginCollections = createMockCollectionList(1)
mockMarketplaceData.pluginCollectionPluginsMap = { mockMarketplaceData.pluginCollectionPluginsMap = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
@ -1421,20 +1434,21 @@ describe('Combined Workflows', () => {
rerender(<ListWrapper />) rerender(<ListWrapper />)
expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() expect(screen.queryByText('Collection 0')).not.toBeInTheDocument()
expect(screen.getByText('5 plugins found')).toBeInTheDocument() expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
}) })
it('should handle empty search results', () => { it('should handle empty search results', () => {
mockSearchMode = true
mockMarketplaceData.plugins = [] mockMarketplaceData.plugins = []
mockMarketplaceData.pluginsTotal = 0 mockMarketplaceData.pluginsTotal = 0
render(<ListWrapper />) render(<ListWrapper />)
expect(screen.getByTestId('empty-component')).toBeInTheDocument() expect(screen.getByTestId('empty-component')).toBeInTheDocument()
expect(screen.getByText('0 plugins found')).toBeInTheDocument()
}) })
it('should support pagination (page > 1)', () => { it('should support pagination (page > 1)', () => {
mockSearchMode = true
mockMarketplaceData.plugins = createMockPluginList(40) mockMarketplaceData.plugins = createMockPluginList(40)
mockMarketplaceData.pluginsTotal = 80 mockMarketplaceData.pluginsTotal = 80
mockMarketplaceData.isLoading = true mockMarketplaceData.isLoading = true
@ -1442,9 +1456,7 @@ describe('Combined Workflows', () => {
render(<ListWrapper />) render(<ListWrapper />)
// Should show existing results while loading more expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
expect(screen.getByText('80 plugins found')).toBeInTheDocument()
// Should not show loading spinner for pagination
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
}) })
}) })

View File

@ -21,6 +21,11 @@ const { mockMarketplaceData } = vi.hoisted(() => ({
vi.mock('../state', () => ({ vi.mock('../state', () => ({
useMarketplaceData: () => mockMarketplaceData, useMarketplaceData: () => mockMarketplaceData,
isPluginsData: (data: typeof mockMarketplaceData) => data.creationType === 'plugins',
}))
vi.mock('../atoms', () => ({
useMarketplaceSearchMode: () => false,
})) }))
vi.mock('@/app/components/base/loading', () => ({ vi.mock('@/app/components/base/loading', () => ({

View File

@ -44,9 +44,8 @@ vi.mock('next/link', () => ({
), ),
})) }))
// Mock next-themes vi.mock('@/hooks/use-theme', () => ({
vi.mock('next-themes', () => ({ default: () => ({ theme: 'light' }),
useTheme: () => ({ theme: 'light' }),
})) }))
// Mock marketplace utils // Mock marketplace utils
@ -66,16 +65,20 @@ vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({
default: ({ text }: { text: string }) => <span data-testid="corner-mark">{text}</span>, default: ({ text }: { text: string }) => <span data-testid="corner-mark">{text}</span>,
})) }))
// Mock marketplace utils (getTemplateIconUrl) // Mock marketplace utils (getTemplateIconUrl) while keeping other exports intact.
vi.mock('../utils', () => ({ vi.mock('../utils', async () => {
getTemplateIconUrl: (template: { id: string, icon?: string, icon_file_key?: string }): string => { const actual = await vi.importActual<typeof import('../utils')>('../utils')
if (template.icon?.startsWith('http')) return {
return template.icon ...actual,
if (template.icon_file_key) getTemplateIconUrl: (template: { id: string, icon?: string, icon_file_key?: string }): string => {
return `https://marketplace.dify.ai/api/v1/templates/${template.id}/icon` if (template.icon?.startsWith('http'))
return '' return template.icon
}, if (template.icon_file_key)
})) return `https://marketplace.dify.ai/api/v1/templates/${template.id}/icon`
return ''
},
}
})
// ================================ // ================================
// Test Data Factories // Test Data Factories

View File

@ -41,10 +41,18 @@ vi.mock('ahooks', () => ({
useDebounce: (value: string) => value, useDebounce: (value: string) => value,
})) }))
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('jotai', async () => { vi.mock('jotai', async () => {
const actual = await vi.importActual<typeof import('jotai')>('jotai') const actual = await vi.importActual<typeof import('jotai')>('jotai')
return { return {
...actual, ...actual,
useAtomValue: () => false,
useSetAtom: () => vi.fn(), useSetAtom: () => vi.fn(),
} }
}) })
@ -61,6 +69,7 @@ vi.mock('@/hooks/use-i18n', () => ({
const { const {
mockSearchText, mockSearchText,
mockHandleSearchTextChange, mockHandleSearchTextChange,
mockSetSearchTab,
mockFilterPluginTags, mockFilterPluginTags,
mockHandleFilterPluginTagsChange, mockHandleFilterPluginTagsChange,
mockActivePluginCategory, mockActivePluginCategory,
@ -69,6 +78,7 @@ const {
return { return {
mockSearchText: '', mockSearchText: '',
mockHandleSearchTextChange: vi.fn(), mockHandleSearchTextChange: vi.fn(),
mockSetSearchTab: vi.fn(),
mockFilterPluginTags: [] as string[], mockFilterPluginTags: [] as string[],
mockHandleFilterPluginTagsChange: vi.fn(), mockHandleFilterPluginTagsChange: vi.fn(),
mockActivePluginCategory: 'all', mockActivePluginCategory: 'all',
@ -87,8 +97,9 @@ vi.mock('../../atoms', () => ({
useMarketplaceTemplateSortValue: () => ({ sortBy: 'usage_count', sortOrder: 'DESC' }), useMarketplaceTemplateSortValue: () => ({ sortBy: 'usage_count', sortOrder: 'DESC' }),
useActiveSort: () => [mockSortValue, vi.fn()], useActiveSort: () => [mockSortValue, vi.fn()],
useActiveSortValue: () => mockSortValue, useActiveSortValue: () => mockSortValue,
useCreationType: () => ['plugins', vi.fn()], useCreationType: () => 'plugins',
useSearchTab: () => ['', vi.fn()], useSearchTab: () => ['', mockSetSearchTab],
isMarketplacePlatformAtom: {},
searchModeAtom: {}, searchModeAtom: {},
})) }))
@ -135,7 +146,7 @@ vi.mock('@/app/components/plugins/hooks', () => ({
})) }))
let mockDropdownPlugins: Plugin[] = [] let mockDropdownPlugins: Plugin[] = []
vi.mock('../query', () => ({ vi.mock('../../query', () => ({
useMarketplaceUnifiedSearch: () => ({ useMarketplaceUnifiedSearch: () => ({
data: { data: {
plugins: { items: mockDropdownPlugins, total: mockDropdownPlugins.length }, plugins: { items: mockDropdownPlugins, total: mockDropdownPlugins.length },
@ -233,6 +244,7 @@ describe('SearchBox', () => {
vi.clearAllMocks() vi.clearAllMocks()
mockPortalOpenState = false mockPortalOpenState = false
mockDropdownPlugins = [] mockDropdownPlugins = []
mockRouterPush.mockReset()
}) })
// ================================ // ================================
@ -630,6 +642,7 @@ describe('SearchBoxWrapper', () => {
vi.clearAllMocks() vi.clearAllMocks()
mockPortalOpenState = false mockPortalOpenState = false
mockDropdownPlugins = [] mockDropdownPlugins = []
mockRouterPush.mockReset()
}) })
describe('Rendering', () => { describe('Rendering', () => {
@ -639,17 +652,10 @@ describe('SearchBoxWrapper', () => {
expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument()
}) })
it('should render in marketplace mode', () => { it('should apply custom wrapper class to the input wrapper', () => {
const { container } = render(<SearchBoxWrapper />) const { container } = render(<SearchBoxWrapper wrapperClassName="custom-wrapper" />)
expect(container.querySelector('.rounded-xl')).toBeInTheDocument() expect(container.querySelector('.custom-wrapper')).toBeInTheDocument()
})
it('should apply correct wrapper classes', () => {
const { container } = render(<SearchBoxWrapper />)
// Check for z-[11] class from wrapper
expect(container.querySelector('.z-\\[11\\]')).toBeInTheDocument()
}) })
}) })
@ -671,6 +677,7 @@ describe('SearchBoxWrapper', () => {
fireEvent.keyDown(input, { key: 'Enter' }) fireEvent.keyDown(input, { key: 'Enter' })
expect(mockHandleSearchTextChange).toHaveBeenCalledWith('new search') expect(mockHandleSearchTextChange).toHaveBeenCalledWith('new search')
expect(mockSetSearchTab).toHaveBeenCalledWith('all')
}) })
it('should clear committed search when input is emptied and blurred', () => { it('should clear committed search when input is emptied and blurred', () => {
@ -702,7 +709,7 @@ describe('SearchBoxWrapper', () => {
it('should use translation for placeholder', () => { it('should use translation for placeholder', () => {
render(<SearchBoxWrapper />) render(<SearchBoxWrapper />)
expect(screen.getByPlaceholderText('Search plugins')).toBeInTheDocument() expect(screen.getByPlaceholderText('searchInMarketplace')).toBeInTheDocument()
}) })
}) })
}) })

View File

@ -1,15 +1,8 @@
import { fireEvent, render, screen, within } from '@testing-library/react' import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import SortDropdown from '../index' import SortDropdown from '../index'
// ================================
// Mock external dependencies only
// ================================
// Mock i18n translation hook
const mockTranslation = vi.fn((key: string, options?: { ns?: string }) => { const mockTranslation = vi.fn((key: string, options?: { ns?: string }) => {
// Build full key with namespace prefix if provided
const fullKey = options?.ns ? `${options.ns}.${key}` : key const fullKey = options?.ns ? `${options.ns}.${key}` : key
const translations: Record<string, string> = { const translations: Record<string, string> = {
'plugin.marketplace.sortBy': 'Sort by', 'plugin.marketplace.sortBy': 'Sort by',
@ -27,73 +20,36 @@ vi.mock('#i18n', () => ({
}), }),
})) }))
// Mock marketplace atoms with controllable values
let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' } let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' }
const mockHandleSortChange = vi.fn() const mockHandleSortChange = vi.fn()
let mockCreationType = 'plugins' let mockCreationType = 'plugins'
vi.mock('../atoms', () => ({ vi.mock('../../atoms', () => ({
useActiveSort: () => [mockSort, mockHandleSortChange], useActiveSort: () => [mockSort, mockHandleSortChange],
useCreationType: () => [mockCreationType, vi.fn()], useCreationType: () => mockCreationType,
useMarketplaceSort: () => [mockSort, mockHandleSortChange],
})) }))
vi.mock('../search-params', () => ({ vi.mock('../../search-params', () => ({
CREATION_TYPE: { plugins: 'plugins', templates: 'templates' }, CREATION_TYPE: { plugins: 'plugins', templates: 'templates' },
})) }))
// Mock portal component with controllable open state
let mockPortalOpenState = false let mockPortalOpenState = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: { PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
children: React.ReactNode
open: boolean
onOpenChange: (open: boolean) => void
}) => {
mockPortalOpenState = open mockPortalOpenState = open
return ( return <div data-testid="portal-wrapper" data-open={String(open)}>{children}</div>
<div data-testid="portal-wrapper" data-open={open}>
{children}
</div>
)
}, },
PortalToFollowElemTrigger: ({ children, onClick }: { PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
children: React.ReactNode <div data-testid="portal-trigger" onClick={onClick}>{children}</div>
onClick: () => void
}) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
), ),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
// Match actual behavior: only render when portal is open
if (!mockPortalOpenState) if (!mockPortalOpenState)
return null return null
return <div data-testid="portal-content">{children}</div> return <div data-testid="portal-content">{children}</div>
}, },
})) }))
// ================================
// Test Factory Functions
// ================================
type SortOption = {
value: string
order: string
text: string
}
const createSortOptions = (): SortOption[] => [
{ value: 'install_count', order: 'DESC', text: 'Most Popular' },
{ value: 'version_updated_at', order: 'DESC', text: 'Recently Updated' },
{ value: 'created_at', order: 'DESC', text: 'Newly Released' },
{ value: 'created_at', order: 'ASC', text: 'First Released' },
]
// ================================
// SortDropdown Component Tests
// ================================
describe('SortDropdown', () => { describe('SortDropdown', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@ -102,149 +58,40 @@ describe('SortDropdown', () => {
mockPortalOpenState = false mockPortalOpenState = false
}) })
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => { describe('Rendering', () => {
it('should render without crashing', () => { it('should render selected plugin sort label by default', () => {
render(<SortDropdown />)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should render sort by label', () => {
render(<SortDropdown />) render(<SortDropdown />)
expect(screen.getByText('Sort by')).toBeInTheDocument() expect(screen.getByText('Sort by')).toBeInTheDocument()
})
it('should render selected option text', () => {
render(<SortDropdown />)
expect(screen.getByText('Most Popular')).toBeInTheDocument() expect(screen.getByText('Most Popular')).toBeInTheDocument()
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
}) })
it('should render arrow down icon', () => { it('should render template sort label when creationType is templates', () => {
const { container } = render(<SortDropdown />) mockCreationType = 'templates'
mockSort = { sortBy: 'updated_at', sortOrder: 'DESC' }
const arrowIcon = container.querySelector('.h-4.w-4.text-text-tertiary')
expect(arrowIcon).toBeInTheDocument()
})
it('should render trigger element with correct styles', () => {
const { container } = render(<SortDropdown />)
const trigger = container.querySelector('.cursor-pointer')
expect(trigger).toBeInTheDocument()
expect(trigger).toHaveClass('h-8', 'rounded-lg', 'bg-state-base-hover-alt')
})
it('should not render dropdown content when closed', () => {
render(<SortDropdown />)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
})
// ================================
// State Management Tests
// ================================
describe('State Management', () => {
it('should initialize with closed state', () => {
render(<SortDropdown />)
const wrapper = screen.getByTestId('portal-wrapper')
expect(wrapper).toHaveAttribute('data-open', 'false')
})
it('should display correct selected option for install_count DESC', () => {
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
render(<SortDropdown />)
expect(screen.getByText('Most Popular')).toBeInTheDocument()
})
it('should display correct selected option for version_updated_at DESC', () => {
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
render(<SortDropdown />) render(<SortDropdown />)
expect(screen.getByText('Recently Updated')).toBeInTheDocument() expect(screen.getByText('Recently Updated')).toBeInTheDocument()
}) })
it('should display correct selected option for created_at DESC', () => {
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
render(<SortDropdown />)
expect(screen.getByText('Newly Released')).toBeInTheDocument()
})
it('should display correct selected option for created_at ASC', () => {
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
render(<SortDropdown />)
expect(screen.getByText('First Released')).toBeInTheDocument()
})
it('should toggle open state when trigger clicked', () => {
render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
// After click, portal content should be visible
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should close dropdown when trigger clicked again', () => {
render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger')
// Open
fireEvent.click(trigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Close
fireEvent.click(trigger)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
}) })
// ================================ describe('Interactions', () => {
// User Interactions Tests it('should open the dropdown when trigger is clicked', () => {
// ================================
describe('User Interactions', () => {
it('should open dropdown on trigger click', () => {
render(<SortDropdown />) render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger') fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(trigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument() expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(within(screen.getByTestId('portal-content')).getByText('Recently Updated')).toBeInTheDocument()
}) })
it('should render all sort options when open', () => { it('should call handleSortChange with plugin sort option values', () => {
render(<SortDropdown />) render(<SortDropdown />)
// Open dropdown
fireEvent.click(screen.getByTestId('portal-trigger')) fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(within(screen.getByTestId('portal-content')).getByText('Recently Updated'))
const content = screen.getByTestId('portal-content')
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
expect(within(content).getByText('Recently Updated')).toBeInTheDocument()
expect(within(content).getByText('Newly Released')).toBeInTheDocument()
expect(within(content).getByText('First Released')).toBeInTheDocument()
})
it('should call handleSortChange when option clicked', () => {
render(<SortDropdown />)
// Open dropdown
fireEvent.click(screen.getByTestId('portal-trigger'))
// Click on "Recently Updated"
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('Recently Updated'))
expect(mockHandleSortChange).toHaveBeenCalledWith({ expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'version_updated_at', sortBy: 'version_updated_at',
@ -252,455 +99,28 @@ describe('SortDropdown', () => {
}) })
}) })
it('should call handleSortChange with correct params for Most Popular', () => { it('should call handleSortChange with template sort option values', () => {
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' } mockCreationType = 'templates'
render(<SortDropdown />) render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger')) fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(within(screen.getByTestId('portal-content')).getByText('Most Popular'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('Most Popular'))
expect(mockHandleSortChange).toHaveBeenCalledWith({ expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'install_count', sortBy: 'usage_count',
sortOrder: 'DESC', sortOrder: 'DESC',
}) })
}) })
it('should call handleSortChange with correct params for Newly Released', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('Newly Released'))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'created_at',
sortOrder: 'DESC',
})
})
it('should call handleSortChange with correct params for First Released', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('First Released'))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'created_at',
sortOrder: 'ASC',
})
})
it('should allow selecting currently selected option', () => {
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('Most Popular'))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'install_count',
sortOrder: 'DESC',
})
})
it('should support userEvent for trigger click', async () => {
const user = userEvent.setup()
render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger')
await user.click(trigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
}) })
// ================================ describe('Selection', () => {
// Check Icon Tests it('should fall back to the first option when the sort does not match any option', () => {
// ================================ mockSort = { sortBy: 'unknown', sortOrder: 'DESC' }
describe('Check Icon', () => {
it('should show check icon for selected option', () => {
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
const { container } = render(<SortDropdown />)
// Open dropdown
fireEvent.click(screen.getByTestId('portal-trigger'))
// Check icon should be present in the dropdown
const checkIcon = container.querySelector('.text-text-accent')
expect(checkIcon).toBeInTheDocument()
})
it('should show check icon only for matching sortBy AND sortOrder', () => {
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const options = content.querySelectorAll('.cursor-pointer')
// "Newly Released" (created_at DESC) should have check icon
// "First Released" (created_at ASC) should NOT have check icon
expect(options.length).toBe(4)
})
it('should not show check icon for different sortOrder with same sortBy', () => {
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
const { container } = render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
// Only one check icon should be visible (for Newly Released, not First Released)
const checkIcons = container.querySelectorAll('.text-text-accent')
expect(checkIcons.length).toBe(1)
})
})
// ================================
// Dropdown Options Structure Tests
// ================================
describe('Dropdown Options Structure', () => {
const sortOptions = createSortOptions()
it('should render 4 sort options', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const options = content.querySelectorAll('.cursor-pointer')
expect(options.length).toBe(4)
})
it.each(sortOptions)('should render option: $text', ({ text }) => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
expect(within(content).getByText(text)).toBeInTheDocument()
})
it('should render options with unique keys', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const options = content.querySelectorAll('.cursor-pointer')
// All options should be rendered (no key conflicts)
expect(options.length).toBe(4)
})
it('should render dropdown container with correct styles', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const container = content.firstChild as HTMLElement
expect(container).toHaveClass('rounded-xl', 'shadow-lg')
})
it('should render option items with hover styles', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const option = content.querySelector('.cursor-pointer')
expect(option).toHaveClass('hover:bg-components-panel-on-panel-item-bg-hover')
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
// The component falls back to the first option (Most Popular) when sort values are invalid
it('should fallback to default option when sortBy is unknown', () => {
mockSort = { sortBy: 'unknown_field', sortOrder: 'DESC' }
render(<SortDropdown />)
// Should fallback to first option "Most Popular"
expect(screen.getByText('Most Popular')).toBeInTheDocument()
})
it('should fallback to default option when sortBy is empty', () => {
mockSort = { sortBy: '', sortOrder: 'DESC' }
render(<SortDropdown />) render(<SortDropdown />)
expect(screen.getByText('Most Popular')).toBeInTheDocument() expect(screen.getByText('Most Popular')).toBeInTheDocument()
}) })
it('should fallback to default option when sortOrder is unknown', () => {
mockSort = { sortBy: 'install_count', sortOrder: 'UNKNOWN' }
render(<SortDropdown />)
expect(screen.getByText('Most Popular')).toBeInTheDocument()
})
it('should render correctly when handleSortChange is a no-op', () => {
mockHandleSortChange.mockImplementation(() => {})
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('Recently Updated'))
expect(mockHandleSortChange).toHaveBeenCalled()
})
it('should handle rapid toggle clicks', () => {
render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger')
// Rapid clicks
fireEvent.click(trigger)
fireEvent.click(trigger)
fireEvent.click(trigger)
// Final state should be open (odd number of clicks)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should handle multiple option selections', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
// Click multiple options
fireEvent.click(within(content).getByText('Recently Updated'))
fireEvent.click(within(content).getByText('Newly Released'))
fireEvent.click(within(content).getByText('First Released'))
expect(mockHandleSortChange).toHaveBeenCalledTimes(3)
})
})
// ================================
// Context Integration Tests
// ================================
describe('Context Integration', () => {
it('should read sort value from context', () => {
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
render(<SortDropdown />)
expect(screen.getByText('Recently Updated')).toBeInTheDocument()
})
it('should call context handleSortChange on selection', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText('First Released'))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'created_at',
sortOrder: 'ASC',
})
})
it('should update display when context sort changes', () => {
const { rerender } = render(<SortDropdown />)
expect(screen.getByText('Most Popular')).toBeInTheDocument()
// Simulate context change
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
rerender(<SortDropdown />)
expect(screen.getByText('First Released')).toBeInTheDocument()
})
it('should use selector pattern correctly', () => {
render(<SortDropdown />)
// Component should have called useMarketplaceContext with selector functions
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
})
// ================================
// Accessibility Tests
// ================================
describe('Accessibility', () => {
it('should have cursor pointer on trigger', () => {
const { container } = render(<SortDropdown />)
const trigger = container.querySelector('.cursor-pointer')
expect(trigger).toBeInTheDocument()
})
it('should have cursor pointer on options', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const options = content.querySelectorAll('.cursor-pointer')
expect(options.length).toBeGreaterThan(0)
})
it('should have visible focus indicators via hover styles', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const option = content.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover')
expect(option).toBeInTheDocument()
})
})
// ================================
// Translation Tests
// ================================
describe('Translations', () => {
it('should call translation for sortBy label', () => {
render(<SortDropdown />)
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortBy', { ns: 'plugin' })
})
it('should call translation for all sort options', () => {
render(<SortDropdown />)
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.mostPopular', { ns: 'plugin' })
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.recentlyUpdated', { ns: 'plugin' })
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.newlyReleased', { ns: 'plugin' })
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.firstReleased', { ns: 'plugin' })
})
})
// ================================
// Portal Component Integration Tests
// ================================
describe('Portal Component Integration', () => {
it('should pass open state to PortalToFollowElem', () => {
render(<SortDropdown />)
const wrapper = screen.getByTestId('portal-wrapper')
expect(wrapper).toHaveAttribute('data-open', 'false')
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(wrapper).toHaveAttribute('data-open', 'true')
})
it('should render trigger content inside PortalToFollowElemTrigger', () => {
render(<SortDropdown />)
const trigger = screen.getByTestId('portal-trigger')
expect(within(trigger).getByText('Sort by')).toBeInTheDocument()
expect(within(trigger).getByText('Most Popular')).toBeInTheDocument()
})
it('should render options inside PortalToFollowElemContent', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
})
})
// ================================
// Visual Style Tests
// ================================
describe('Visual Styles', () => {
it('should apply correct trigger container styles', () => {
const { container } = render(<SortDropdown />)
const triggerDiv = container.querySelector('.flex.h-8.cursor-pointer.items-center.rounded-lg')
expect(triggerDiv).toBeInTheDocument()
})
it('should apply secondary text color to sort by label', () => {
const { container } = render(<SortDropdown />)
const label = container.querySelector('.text-text-secondary')
expect(label).toBeInTheDocument()
expect(label?.textContent).toBe('Sort by')
})
it('should apply primary text color to selected option', () => {
const { container } = render(<SortDropdown />)
const selected = container.querySelector('.text-text-primary.system-sm-medium')
expect(selected).toBeInTheDocument()
})
it('should apply tertiary text color to arrow icon', () => {
const { container } = render(<SortDropdown />)
const arrow = container.querySelector('.text-text-tertiary')
expect(arrow).toBeInTheDocument()
})
it('should apply accent text color to check icon when option selected', () => {
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
const { container } = render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const checkIcon = container.querySelector('.text-text-accent')
expect(checkIcon).toBeInTheDocument()
})
it('should apply blur backdrop to dropdown container', () => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
const container = content.querySelector('.backdrop-blur-sm')
expect(container).toBeInTheDocument()
})
})
// ================================
// All Sort Options Click Tests
// ================================
describe('All Sort Options Click Handlers', () => {
const testCases = [
{ text: 'Most Popular', sortBy: 'install_count', sortOrder: 'DESC' },
{ text: 'Recently Updated', sortBy: 'version_updated_at', sortOrder: 'DESC' },
{ text: 'Newly Released', sortBy: 'created_at', sortOrder: 'DESC' },
{ text: 'First Released', sortBy: 'created_at', sortOrder: 'ASC' },
]
it.each(testCases)(
'should call handleSortChange with { sortBy: "$sortBy", sortOrder: "$sortOrder" } when clicking "$text"',
({ text, sortBy, sortOrder }) => {
render(<SortDropdown />)
fireEvent.click(screen.getByTestId('portal-trigger'))
const content = screen.getByTestId('portal-content')
fireEvent.click(within(content).getByText(text))
expect(mockHandleSortChange).toHaveBeenCalledWith({ sortBy, sortOrder })
},
)
}) })
}) })

View File

@ -610,7 +610,7 @@ describe('PluginMutationModal', () => {
render(<PluginMutationModal {...props} />) render(<PluginMutationModal {...props} />)
expect(screen.getByText('my-organization')).toBeInTheDocument() expect(screen.getByText('my-organization')).toBeInTheDocument()
expect(screen.getByText('my-plugin-name')).toBeInTheDocument() expect(screen.queryByText('my-plugin-name')).not.toBeInTheDocument()
}) })
it('should display plugin category', () => { it('should display plugin category', () => {
@ -750,7 +750,7 @@ describe('PluginMutationModal', () => {
render(<PluginMutationModal {...props} />) render(<PluginMutationModal {...props} />)
expect(screen.getByText('plugin-with-special<chars>!@#$%')).toBeInTheDocument() expect(screen.getByText('org<script>test</script>')).toBeInTheDocument()
}) })
it('should handle very long title', () => { it('should handle very long title', () => {

View File

@ -104,6 +104,10 @@ vi.mock('../install-plugin-dropdown', () => ({
), ),
})) }))
vi.mock('../../marketplace/search-box/search-box-wrapper', () => ({
default: () => <div data-testid="search-box-wrapper">SearchBoxWrapper</div>,
}))
vi.mock('../../install-plugin/install-from-local-package', () => ({ vi.mock('../../install-plugin/install-from-local-package', () => ({
default: ({ onClose }: { onClose: () => void }) => ( default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="install-local-modal"> <div data-testid="install-local-modal">
@ -180,11 +184,7 @@ describe('PluginPage Component', () => {
vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()])
render(<PluginPageWithContext {...createDefaultProps()} />) render(<PluginPageWithContext {...createDefaultProps()} />)
// The marketplace content should be visible when enable_marketplace is true and on discover tab expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
const container = document.getElementById('marketplace-container')
expect(container).toBeInTheDocument()
// Check that marketplace-specific links are shown
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
}) })
it('should render TabSlider', () => { it('should render TabSlider', () => {
@ -225,9 +225,7 @@ describe('PluginPage Component', () => {
vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()])
render(<PluginPageWithContext {...createDefaultProps()} />) render(<PluginPageWithContext {...createDefaultProps()} />)
// Check for marketplace-specific buttons expect(screen.getByText(/requestSubmit/i)).toBeInTheDocument()
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument()
}) })
it('should not show marketplace links when on plugins tab', () => { it('should not show marketplace links when on plugins tab', () => {
@ -548,12 +546,11 @@ describe('PluginPage Component', () => {
const { rerender } = render(<PluginPageWithContext {...createDefaultProps()} />) const { rerender } = render(<PluginPageWithContext {...createDefaultProps()} />)
// Should show marketplace links when on discover tab expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
// Rerender with same props // Rerender with same props
rerender(<PluginPageWithContext {...createDefaultProps()} />) rerender(<PluginPageWithContext {...createDefaultProps()} />)
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument() expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
}) })
it('should recognize plugin type tabs as marketplace', () => { it('should recognize plugin type tabs as marketplace', () => {
@ -562,9 +559,8 @@ describe('PluginPage Component', () => {
render(<PluginPageWithContext {...createDefaultProps()} />) render(<PluginPageWithContext {...createDefaultProps()} />)
// Should show marketplace links when on a plugin type tab expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument() expect(screen.getByText(/requestSubmit/i)).toBeInTheDocument()
expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument()
}) })
it('should render marketplace content when isExploringMarketplace and enable_marketplace are true', () => { it('should render marketplace content when isExploringMarketplace and enable_marketplace are true', () => {
@ -572,11 +568,7 @@ describe('PluginPage Component', () => {
render(<PluginPageWithContext {...createDefaultProps()} />) render(<PluginPageWithContext {...createDefaultProps()} />)
// The marketplace prop content should be rendered expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
// Since we mock the marketplace as a div, check it's not hidden
const container = document.getElementById('marketplace-container')
expect(container).toBeInTheDocument()
expect(container).toHaveClass('bg-background-body')
}) })
}) })
@ -616,6 +608,7 @@ describe('PluginPage Component', () => {
render(<PluginPageWithContext plugins={null} marketplace={null} />) render(<PluginPageWithContext plugins={null} marketplace={null} />)
expect(document.getElementById('marketplace-container')).toBeInTheDocument() expect(document.getElementById('marketplace-container')).toBeInTheDocument()
expect(screen.queryByTestId('marketplace-content')).not.toBeInTheDocument()
}) })
it('should handle rapid tab switches', async () => { it('should handle rapid tab switches', async () => {
@ -638,8 +631,8 @@ describe('PluginPage Component', () => {
render(<PluginPageWithContext {...createDefaultProps()} />) render(<PluginPageWithContext {...createDefaultProps()} />)
// Component should still render but without marketplace content when disabled
expect(document.getElementById('marketplace-container')).toBeInTheDocument() expect(document.getElementById('marketplace-container')).toBeInTheDocument()
expect(screen.queryByTestId('marketplace-content')).not.toBeInTheDocument()
}) })
it('should handle file with empty name', async () => { it('should handle file with empty name', async () => {
@ -731,7 +724,7 @@ describe('PluginPage Component', () => {
render(<PluginPageWithContext {...createDefaultProps()} />) render(<PluginPageWithContext {...createDefaultProps()} />)
const container = document.getElementById('marketplace-container') const container = document.getElementById('marketplace-container')
expect(container).toHaveClass('bg-background-body') expect(container).toHaveClass('bg-components-panel-bg')
}) })
it('should have scrollbar-gutter stable style', () => { it('should have scrollbar-gutter stable style', () => {
@ -1027,11 +1020,10 @@ describe('PluginPage Integration', () => {
const { rerender } = render(<PluginPageWithContext {...createDefaultProps()} />) const { rerender } = render(<PluginPageWithContext {...createDefaultProps()} />)
// With enable_marketplace: true (default mock), marketplace links should show expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
// Rerender to verify consistent behavior // Rerender to verify consistent behavior
rerender(<PluginPageWithContext {...createDefaultProps()} />) rerender(<PluginPageWithContext {...createDefaultProps()} />)
expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument() expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
}) })
}) })