Files
dify/web/app/components/plugins/card/__tests__/index.spec.tsx
CodingOnStar c167ee199c feat: implement dynamic plugin card icon URL generation
Added a utility function to generate plugin card icon URLs based on the plugin's source and workspace context. Updated the Card component to utilize this function for determining the correct icon source. Enhanced unit tests to verify the correct URL generation for both marketplace and package icons.
2026-03-12 14:58:16 +08:00

627 lines
19 KiB
TypeScript

import type { Plugin } from '../../types'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
import { PluginCategoryEnum } from '../../types'
import Card from '../index'
let mockTheme = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme }),
}))
vi.mock('@/i18n-config', () => ({
renderI18nObject: (obj: Record<string, string>, locale: string) => {
return obj?.[locale] || obj?.['en-US'] || ''
},
}))
vi.mock('@/i18n-config/language', () => ({
getLanguage: (locale: string) => locale || 'en-US',
}))
const mockCategoriesMap: Record<string, { label: string }> = {
'tool': { label: 'Tool' },
'model': { label: 'Model' },
'extension': { label: 'Extension' },
'agent-strategy': { label: 'Agent' },
'datasource': { label: 'Datasource' },
'trigger': { label: 'Trigger' },
'bundle': { label: 'Bundle' },
}
vi.mock('../../hooks', () => ({
useCategories: () => ({
categoriesMap: mockCategoriesMap,
}),
}))
vi.mock('@/utils/format', () => ({
formatNumber: (num: number) => num.toLocaleString(),
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (value: { currentWorkspace: { id: string } }) => string) => selector({
currentWorkspace: { id: 'workspace-123' },
}),
}))
vi.mock('@/utils/mcp', () => ({
shouldUseMcpIcon: (src: unknown) => typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗',
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ icon, background, innerIcon, size, iconType }: {
icon?: string
background?: string
innerIcon?: React.ReactNode
size?: string
iconType?: string
}) => (
<div
data-testid="app-icon"
data-icon={icon}
data-background={background}
data-size={size}
data-icon-type={iconType}
>
{!!innerIcon && <div data-testid="inner-icon">{innerIcon}</div>}
</div>
),
}))
vi.mock('@/app/components/base/icons/src/vender/other', () => ({
Mcp: ({ className }: { className?: string }) => (
<div data-testid="mcp-icon" className={className}>MCP</div>
),
Group: ({ className }: { className?: string }) => (
<div data-testid="group-icon" className={className}>Group</div>
),
}))
vi.mock('../../../base/icons/src/vender/plugin', () => ({
LeftCorner: ({ className }: { className?: string }) => (
<div data-testid="left-corner" className={className}>LeftCorner</div>
),
}))
vi.mock('../../base/badges/partner', () => ({
default: ({ className, text }: { className?: string, text?: string }) => (
<div data-testid="partner-badge" className={className} title={text}>Partner</div>
),
}))
vi.mock('../../base/badges/verified', () => ({
default: ({ className, text }: { className?: string, text?: string }) => (
<div data-testid="verified-badge" className={className} title={text}>Verified</div>
),
}))
vi.mock('@/app/components/base/skeleton', () => ({
SkeletonContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="skeleton-container">{children}</div>
),
SkeletonPoint: () => <div data-testid="skeleton-point" />,
SkeletonRectangle: ({ className }: { className?: string }) => (
<div data-testid="skeleton-rectangle" className={className} />
),
SkeletonRow: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="skeleton-row" className={className}>{children}</div>
),
}))
const createMockPlugin = (overrides?: Partial<Plugin>): Plugin => ({
type: 'plugin',
org: 'test-org',
name: 'test-plugin',
plugin_id: 'plugin-123',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'test-org/test-plugin:1.0.0',
icon: '/test-icon.png',
verified: false,
label: { 'en-US': 'Test Plugin' },
brief: { 'en-US': 'Test plugin description' },
description: { 'en-US': 'Full test plugin description' },
introduction: 'Test plugin introduction',
repository: 'https://github.com/test/plugin',
category: PluginCategoryEnum.tool,
install_count: 1000,
endpoint: { settings: [] },
tags: [{ name: 'search' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
describe('Card', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render without crashing', () => {
const plugin = createMockPlugin()
render(<Card payload={plugin} />)
expect(document.body).toBeInTheDocument()
})
it('should render plugin title from label', () => {
const plugin = createMockPlugin({
label: { 'en-US': 'My Plugin Title' },
})
render(<Card payload={plugin} />)
expect(screen.getByText('My Plugin Title')).toBeInTheDocument()
})
it('should render plugin description from brief', () => {
const plugin = createMockPlugin({
brief: { 'en-US': 'This is a brief description' },
})
render(<Card payload={plugin} />)
expect(screen.getByText('This is a brief description')).toBeInTheDocument()
})
it('should render organization info with org name and package name', () => {
const plugin = createMockPlugin({
org: 'my-org',
name: 'my-plugin',
})
render(<Card payload={plugin} />)
expect(screen.getByText('my-org')).toBeInTheDocument()
expect(screen.getByText('my-plugin')).toBeInTheDocument()
})
it('should render plugin icon', () => {
const plugin = createMockPlugin({
icon: '/custom-icon.png',
})
const { container } = render(<Card payload={plugin} />)
// Check for background image style on icon element
const iconElement = container.querySelector('[style*="background-image"]')
expect(iconElement).toBeInTheDocument()
})
it('should normalize package icon filenames to workspace icon urls', () => {
const plugin = createMockPlugin({
from: 'package',
icon: 'custom-icon.png',
})
const { container } = render(<Card payload={plugin} />)
const iconElement = container.querySelector('[style*="background-image"]')
expect(iconElement).toBeInTheDocument()
expect(iconElement).toHaveStyle({
backgroundImage: `url(${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-123&filename=custom-icon.png)`,
})
})
it('should normalize marketplace icon filenames to marketplace icon urls', () => {
const plugin = createMockPlugin({
from: 'marketplace',
icon: 'custom-icon.png',
})
const { container } = render(<Card payload={plugin} />)
const iconElement = container.querySelector('[style*="background-image"]')
expect(iconElement).toBeInTheDocument()
expect(iconElement).toHaveStyle({
backgroundImage: `url(${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon)`,
})
})
it('should use icon_dark when theme is dark and icon_dark is provided', () => {
// Set theme to dark
mockTheme = 'dark'
const plugin = createMockPlugin({
icon: '/light-icon.png',
icon_dark: '/dark-icon.png',
})
const { container } = render(<Card payload={plugin} />)
// Check that icon uses dark icon
const iconElement = container.querySelector('[style*="background-image"]')
expect(iconElement).toBeInTheDocument()
expect(iconElement).toHaveStyle({ backgroundImage: 'url(/dark-icon.png)' })
// Reset theme
mockTheme = 'light'
})
it('should use icon when theme is dark but icon_dark is not provided', () => {
mockTheme = 'dark'
const plugin = createMockPlugin({
icon: '/light-icon.png',
})
const { container } = render(<Card payload={plugin} />)
// Should fallback to light icon
const iconElement = container.querySelector('[style*="background-image"]')
expect(iconElement).toBeInTheDocument()
expect(iconElement).toHaveStyle({ backgroundImage: 'url(/light-icon.png)' })
mockTheme = 'light'
})
it('should render corner mark with category label', () => {
const plugin = createMockPlugin({
category: PluginCategoryEnum.tool,
})
render(<Card payload={plugin} />)
expect(screen.getByText('Tool')).toBeInTheDocument()
})
})
// ================================
// Props Testing
// ================================
describe('Props', () => {
it('should apply custom className', () => {
const plugin = createMockPlugin()
const { container } = render(
<Card payload={plugin} className="custom-class" />,
)
expect(container.querySelector('.custom-class')).toBeInTheDocument()
})
it('should hide corner mark when hideCornerMark is true', () => {
const plugin = createMockPlugin({
category: PluginCategoryEnum.tool,
})
render(<Card payload={plugin} hideCornerMark={true} />)
expect(screen.queryByTestId('left-corner')).not.toBeInTheDocument()
})
it('should show corner mark by default', () => {
const plugin = createMockPlugin()
render(<Card payload={plugin} />)
expect(screen.getByTestId('left-corner')).toBeInTheDocument()
})
it('should pass installed prop to Icon component', () => {
const plugin = createMockPlugin()
const { container } = render(<Card payload={plugin} installed={true} />)
expect(container.querySelector('.bg-state-success-solid')).toBeInTheDocument()
})
it('should pass installFailed prop to Icon component', () => {
const plugin = createMockPlugin()
const { container } = render(<Card payload={plugin} installFailed={true} />)
expect(container.querySelector('.bg-state-destructive-solid')).toBeInTheDocument()
})
it('should render footer when provided', () => {
const plugin = createMockPlugin()
render(
<Card payload={plugin} footer={<div data-testid="custom-footer">Footer Content</div>} />,
)
expect(screen.getByTestId('custom-footer')).toBeInTheDocument()
expect(screen.getByText('Footer Content')).toBeInTheDocument()
})
it('should render titleLeft when provided', () => {
const plugin = createMockPlugin()
render(
<Card payload={plugin} titleLeft={<span data-testid="title-left">v1.0</span>} />,
)
expect(screen.getByTestId('title-left')).toBeInTheDocument()
})
it('should use custom descriptionLineRows', () => {
const plugin = createMockPlugin()
const { container } = render(
<Card payload={plugin} descriptionLineRows={1} />,
)
// Check for h-4 truncate class when descriptionLineRows is 1
expect(container.querySelector('.h-4.truncate')).toBeInTheDocument()
})
it('should use default descriptionLineRows of 2', () => {
const plugin = createMockPlugin()
const { container } = render(<Card payload={plugin} />)
// Check for h-8 line-clamp-2 class when descriptionLineRows is 2 (default)
expect(container.querySelector('.h-8.line-clamp-2')).toBeInTheDocument()
})
})
// ================================
// Loading State Tests
// ================================
describe('Loading State', () => {
it('should render Placeholder when isLoading is true', () => {
const plugin = createMockPlugin()
render(<Card payload={plugin} isLoading={true} loadingFileName="loading.txt" />)
// Should render skeleton elements
expect(screen.getByTestId('skeleton-container')).toBeInTheDocument()
})
it('should render loadingFileName in Placeholder', () => {
const plugin = createMockPlugin()
render(<Card payload={plugin} isLoading={true} loadingFileName="my-plugin.zip" />)
expect(screen.getByText('my-plugin.zip')).toBeInTheDocument()
})
it('should not render card content when loading', () => {
const plugin = createMockPlugin({
label: { 'en-US': 'Plugin Title' },
})
render(<Card payload={plugin} isLoading={true} loadingFileName="file.txt" />)
// Plugin content should not be visible during loading
expect(screen.queryByText('Plugin Title')).not.toBeInTheDocument()
})
it('should not render loading state by default', () => {
const plugin = createMockPlugin()
render(<Card payload={plugin} />)
expect(screen.queryByTestId('skeleton-container')).not.toBeInTheDocument()
})
})
// ================================
// Badges Tests
// ================================
describe('Badges', () => {
it('should render Partner badge when badges includes partner', () => {
const plugin = createMockPlugin({
badges: ['partner'],
})
render(<Card payload={plugin} />)
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
})
it('should render Verified badge when verified is true', () => {
const plugin = createMockPlugin({
verified: true,
})
render(<Card payload={plugin} />)
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
})
it('should render both Partner and Verified badges', () => {
const plugin = createMockPlugin({
badges: ['partner'],
verified: true,
})
render(<Card payload={plugin} />)
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
})
it('should not render Partner badge when badges is empty', () => {
const plugin = createMockPlugin({
badges: [],
})
render(<Card payload={plugin} />)
expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument()
})
it('should not render Verified badge when verified is false', () => {
const plugin = createMockPlugin({
verified: false,
})
render(<Card payload={plugin} />)
expect(screen.queryByTestId('verified-badge')).not.toBeInTheDocument()
})
it('should handle undefined badges gracefully', () => {
const plugin = createMockPlugin()
// @ts-expect-error - Testing undefined badges
plugin.badges = undefined
render(<Card payload={plugin} />)
expect(screen.queryByTestId('partner-badge')).not.toBeInTheDocument()
})
})
// ================================
// Limited Install Warning Tests
// ================================
describe('Limited Install Warning', () => {
it('should render warning when limitedInstall is true', () => {
const plugin = createMockPlugin()
const { container } = render(<Card payload={plugin} limitedInstall={true} />)
expect(container.querySelector('.text-text-warning-secondary')).toBeInTheDocument()
})
it('should not render warning by default', () => {
const plugin = createMockPlugin()
const { container } = render(<Card payload={plugin} />)
expect(container.querySelector('.text-text-warning-secondary')).not.toBeInTheDocument()
})
it('should apply limited padding when limitedInstall is true', () => {
const plugin = createMockPlugin()
const { container } = render(<Card payload={plugin} limitedInstall={true} />)
expect(container.querySelector('.pb-1')).toBeInTheDocument()
})
})
// ================================
// Category Type Tests
// ================================
describe('Category Types', () => {
it('should display bundle label for bundle type', () => {
const plugin = createMockPlugin({
type: 'bundle',
category: PluginCategoryEnum.tool,
})
render(<Card payload={plugin} />)
// For bundle type, should show 'Bundle' instead of category
expect(screen.getByText('Bundle')).toBeInTheDocument()
})
it('should display category label for non-bundle types', () => {
const plugin = createMockPlugin({
type: 'plugin',
category: PluginCategoryEnum.model,
})
render(<Card payload={plugin} />)
expect(screen.getByText('Model')).toBeInTheDocument()
})
})
// ================================
// Memoization Tests
// ================================
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
// Card is wrapped with React.memo
expect(Card).toBeDefined()
// The component should have the memo display name characteristic
expect(typeof Card).toBe('object')
})
it('should not re-render when props are the same', () => {
const plugin = createMockPlugin()
const renderCount = vi.fn()
const TestWrapper = ({ p }: { p: Plugin }) => {
renderCount()
return <Card payload={p} />
}
const { rerender } = render(<TestWrapper p={plugin} />)
expect(renderCount).toHaveBeenCalledTimes(1)
// Re-render with same plugin reference
rerender(<TestWrapper p={plugin} />)
expect(renderCount).toHaveBeenCalledTimes(2)
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle empty label object', () => {
const plugin = createMockPlugin({
label: {},
})
render(<Card payload={plugin} />)
// Should render without crashing
expect(document.body).toBeInTheDocument()
})
it('should handle empty brief object', () => {
const plugin = createMockPlugin({
brief: {},
})
render(<Card payload={plugin} />)
expect(document.body).toBeInTheDocument()
})
it('should handle undefined label', () => {
const plugin = createMockPlugin()
// @ts-expect-error - Testing undefined label
plugin.label = undefined
render(<Card payload={plugin} />)
expect(document.body).toBeInTheDocument()
})
it('should handle special characters in plugin name', () => {
const plugin = createMockPlugin({
name: 'plugin-with-special-chars!@#$%',
org: 'org<script>alert(1)</script>',
})
render(<Card payload={plugin} />)
expect(screen.getByText('plugin-with-special-chars!@#$%')).toBeInTheDocument()
})
it('should handle very long title', () => {
const longTitle = 'A'.repeat(500)
const plugin = createMockPlugin({
label: { 'en-US': longTitle },
})
const { container } = render(<Card payload={plugin} />)
// Should have truncate class for long text
expect(container.querySelector('.truncate')).toBeInTheDocument()
})
it('should handle very long description', () => {
const longDescription = 'B'.repeat(1000)
const plugin = createMockPlugin({
brief: { 'en-US': longDescription },
})
const { container } = render(<Card payload={plugin} />)
// Should have line-clamp class for long text
expect(container.querySelector('.line-clamp-2')).toBeInTheDocument()
})
})
})