diff --git a/web/app/components/datasets/common/economical-retrieval-method-config/index.spec.tsx b/web/app/components/datasets/common/economical-retrieval-method-config/index.spec.tsx
new file mode 100644
index 0000000000..cd6b050336
--- /dev/null
+++ b/web/app/components/datasets/common/economical-retrieval-method-config/index.spec.tsx
@@ -0,0 +1,101 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { RETRIEVE_METHOD } from '@/types/app'
+import EconomicalRetrievalMethodConfig from './index'
+
+// Mock dependencies
+vi.mock('../../settings/option-card', () => ({
+ default: ({ children, title, description, disabled, id }: {
+ children?: React.ReactNode
+ title?: string
+ description?: React.ReactNode
+ disabled?: boolean
+ id?: string
+ }) => (
+
+
{description}
+ {children}
+
+ ),
+}))
+
+vi.mock('../retrieval-param-config', () => ({
+ default: ({ value, onChange, type }: {
+ value: Record
+ onChange: (value: Record) => void
+ type?: string
+ }) => (
+
+
+
+ ),
+}))
+
+vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({
+ VectorSearch: () => ,
+}))
+
+describe('EconomicalRetrievalMethodConfig', () => {
+ const mockOnChange = vi.fn()
+ const defaultProps = {
+ value: {
+ search_method: RETRIEVE_METHOD.keywordSearch,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 2,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ },
+ onChange: mockOnChange,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render correctly', () => {
+ render()
+
+ expect(screen.getByTestId('option-card')).toBeInTheDocument()
+ expect(screen.getByTestId('retrieval-param-config')).toBeInTheDocument()
+ // Check if title and description are rendered (mocked i18n returns key)
+ expect(screen.getByText('dataset.retrieval.keyword_search.description')).toBeInTheDocument()
+ })
+
+ it('should pass correct props to OptionCard', () => {
+ render()
+
+ const card = screen.getByTestId('option-card')
+ expect(card).toHaveAttribute('data-disabled', 'true')
+ expect(card).toHaveAttribute('data-id', RETRIEVE_METHOD.keywordSearch)
+ })
+
+ it('should pass correct props to RetrievalParamConfig', () => {
+ render()
+
+ const config = screen.getByTestId('retrieval-param-config')
+ expect(config).toHaveAttribute('data-type', RETRIEVE_METHOD.keywordSearch)
+ })
+
+ it('should handle onChange events', () => {
+ render()
+
+ fireEvent.click(screen.getByText('Change Value'))
+
+ expect(mockOnChange).toHaveBeenCalledTimes(1)
+ expect(mockOnChange).toHaveBeenCalledWith({
+ ...defaultProps.value,
+ newProp: 'changed',
+ })
+ })
+
+ it('should default disabled prop to false', () => {
+ render()
+ const card = screen.getByTestId('option-card')
+ expect(card).toHaveAttribute('data-disabled', 'false')
+ })
+})
diff --git a/web/app/components/datasets/common/retrieval-method-info/index.spec.tsx b/web/app/components/datasets/common/retrieval-method-info/index.spec.tsx
new file mode 100644
index 0000000000..05750711dc
--- /dev/null
+++ b/web/app/components/datasets/common/retrieval-method-info/index.spec.tsx
@@ -0,0 +1,148 @@
+import type { ReactNode } from 'react'
+import { render, screen } from '@testing-library/react'
+import { RETRIEVE_METHOD } from '@/types/app'
+import { retrievalIcon } from '../../create/icons'
+import RetrievalMethodInfo, { getIcon } from './index'
+
+// Mock next/image
+vi.mock('next/image', () => ({
+ default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
+
+ ),
+}))
+
+// Mock RadioCard
+vi.mock('@/app/components/base/radio-card', () => ({
+ default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
+
+
{title}
+
{description}
+
{icon}
+
{chosenConfig}
+
+ ),
+}))
+
+// Mock icons
+vi.mock('../../create/icons', () => ({
+ retrievalIcon: {
+ vector: 'vector-icon.png',
+ fullText: 'fulltext-icon.png',
+ hybrid: 'hybrid-icon.png',
+ },
+}))
+
+describe('RetrievalMethodInfo', () => {
+ const defaultConfig = {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: 'test-provider',
+ reranking_model_name: 'test-model',
+ },
+ top_k: 5,
+ score_threshold_enabled: true,
+ score_threshold: 0.8,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render correctly with full config', () => {
+ render()
+
+ expect(screen.getByTestId('radio-card')).toBeInTheDocument()
+
+ // Check Title & Description (mocked i18n returns key prefixed with ns)
+ expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.semantic_search.title')
+ expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
+
+ // Check Icon
+ const icon = screen.getByTestId('method-icon')
+ expect(icon).toHaveAttribute('src', 'vector-icon.png')
+
+ // Check Config Details
+ expect(screen.getByText('test-model')).toBeInTheDocument() // Rerank model
+ expect(screen.getByText('5')).toBeInTheDocument() // Top K
+ expect(screen.getByText('0.8')).toBeInTheDocument() // Score threshold
+ })
+
+ it('should not render reranking model if missing', () => {
+ const configWithoutRerank = {
+ ...defaultConfig,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ }
+
+ render()
+
+ expect(screen.queryByText('test-model')).not.toBeInTheDocument()
+ // Other fields should still be there
+ expect(screen.getByText('5')).toBeInTheDocument()
+ })
+
+ it('should handle different retrieval methods', () => {
+ // Test Hybrid
+ const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
+ const { unmount } = render()
+
+ expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
+ expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png')
+
+ unmount()
+
+ // Test FullText
+ const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
+ render()
+ expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
+ expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png')
+ })
+
+ describe('getIcon utility', () => {
+ it('should return correct icon for each type', () => {
+ expect(getIcon(RETRIEVE_METHOD.semantic)).toBe(retrievalIcon.vector)
+ expect(getIcon(RETRIEVE_METHOD.fullText)).toBe(retrievalIcon.fullText)
+ expect(getIcon(RETRIEVE_METHOD.hybrid)).toBe(retrievalIcon.hybrid)
+ expect(getIcon(RETRIEVE_METHOD.invertedIndex)).toBe(retrievalIcon.vector)
+ expect(getIcon(RETRIEVE_METHOD.keywordSearch)).toBe(retrievalIcon.vector)
+ })
+
+ it('should return default vector icon for unknown type', () => {
+ // Test fallback branch when type is not in the mapping
+ const unknownType = 'unknown_method' as RETRIEVE_METHOD
+ expect(getIcon(unknownType)).toBe(retrievalIcon.vector)
+ })
+ })
+
+ it('should not render score threshold if disabled', () => {
+ const configWithoutScoreThreshold = {
+ ...defaultConfig,
+ score_threshold_enabled: false,
+ score_threshold: 0,
+ }
+
+ render()
+
+ // score_threshold is still rendered but may be undefined
+ expect(screen.queryByText('0.8')).not.toBeInTheDocument()
+ })
+
+ it('should render correctly with invertedIndex search method', () => {
+ const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
+ render()
+
+ // invertedIndex uses vector icon
+ expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
+ })
+
+ it('should render correctly with keywordSearch search method', () => {
+ const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
+ render()
+
+ // keywordSearch uses vector icon
+ expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
+ })
+})
diff --git a/web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx b/web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx
new file mode 100644
index 0000000000..e0dc60b668
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@testing-library/react'
+import EmbeddingSkeleton from './index'
+
+// Mock Skeleton components
+vi.mock('@/app/components/base/skeleton', () => ({
+ SkeletonContainer: ({ children }: { children?: React.ReactNode }) => {children}
,
+ SkeletonPoint: () => ,
+ SkeletonRectangle: () => ,
+ SkeletonRow: ({ children }: { children?: React.ReactNode }) => {children}
,
+}))
+
+// Mock Divider
+vi.mock('@/app/components/base/divider', () => ({
+ default: () => ,
+}))
+
+describe('EmbeddingSkeleton', () => {
+ it('should render correct number of skeletons', () => {
+ render()
+
+ // It renders 5 CardSkeletons. Each CardSkelton has multiple SkeletonContainers.
+ // Let's count the number of main wrapper divs (loop is 5)
+
+ // Each iteration renders a CardSkeleton and potentially a Divider.
+ // The component structure is:
+ // div.relative...
+ // div.absolute... (mask)
+ // map(5) -> div.w-full.px-11 -> CardSkelton + Divider (except last?)
+
+ // Actually the code says `index !== 9`, but the loop is length 5.
+ // So `index` goes 0..4. All are !== 9. So 5 dividers should be rendered.
+
+ expect(screen.getAllByTestId('divider')).toHaveLength(5)
+
+ // Just ensure it renders without crashing and contains skeleton elements
+ expect(screen.getAllByTestId('skeleton-container').length).toBeGreaterThan(0)
+ expect(screen.getAllByTestId('skeleton-rectangle').length).toBeGreaterThan(0)
+ })
+
+ it('should render the mask overlay', () => {
+ const { container } = render()
+ // Check for the absolute positioned mask
+ const mask = container.querySelector('.bg-dataset-chunk-list-mask-bg')
+ expect(mask).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/datasets/list/dataset-footer/index.spec.tsx b/web/app/components/datasets/list/dataset-footer/index.spec.tsx
new file mode 100644
index 0000000000..b59990c682
--- /dev/null
+++ b/web/app/components/datasets/list/dataset-footer/index.spec.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@testing-library/react'
+import DatasetFooter from './index'
+
+describe('DatasetFooter', () => {
+ it('should render correctly', () => {
+ render()
+
+ // Check main title (mocked i18n returns ns:key or key)
+ // The code uses t('didYouKnow', { ns: 'dataset' })
+ // With default mock it likely returns 'dataset.didYouKnow'
+ expect(screen.getByText('dataset.didYouKnow')).toBeInTheDocument()
+
+ // Check paragraph content
+ expect(screen.getByText(/dataset.intro1/)).toBeInTheDocument()
+ expect(screen.getByText(/dataset.intro2/)).toBeInTheDocument()
+ expect(screen.getByText(/dataset.intro3/)).toBeInTheDocument()
+ expect(screen.getByText(/dataset.intro4/)).toBeInTheDocument()
+ expect(screen.getByText(/dataset.intro5/)).toBeInTheDocument()
+ expect(screen.getByText(/dataset.intro6/)).toBeInTheDocument()
+ })
+
+ it('should have correct styling', () => {
+ const { container } = render()
+ const footer = container.querySelector('footer')
+ expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
+
+ const h3 = container.querySelector('h3')
+ expect(h3).toHaveClass('text-gradient')
+ })
+})
diff --git a/web/app/components/datasets/list/new-dataset-card/index.spec.tsx b/web/app/components/datasets/list/new-dataset-card/index.spec.tsx
new file mode 100644
index 0000000000..b361beb9f1
--- /dev/null
+++ b/web/app/components/datasets/list/new-dataset-card/index.spec.tsx
@@ -0,0 +1,49 @@
+import { render, screen } from '@testing-library/react'
+import NewDatasetCard from './index'
+
+type MockOptionProps = {
+ text: string
+ href: string
+}
+
+// Mock dependencies
+vi.mock('./option', () => ({
+ default: ({ text, href }: MockOptionProps) => (
+
+ {text}
+
+ ),
+}))
+
+vi.mock('@remixicon/react', () => ({
+ RiAddLine: () => ,
+ RiFunctionAddLine: () => ,
+}))
+
+vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({
+ ApiConnectionMod: () => ,
+}))
+
+describe('NewDatasetCard', () => {
+ it('should render all options', () => {
+ render()
+
+ const options = screen.getAllByTestId('option-link')
+ expect(options).toHaveLength(3)
+
+ // Check first option (Create Dataset)
+ const createDataset = options[0]
+ expect(createDataset).toHaveAttribute('href', '/datasets/create')
+ expect(createDataset).toHaveTextContent('dataset.createDataset')
+
+ // Check second option (Create from Pipeline)
+ const createFromPipeline = options[1]
+ expect(createFromPipeline).toHaveAttribute('href', '/datasets/create-from-pipeline')
+ expect(createFromPipeline).toHaveTextContent('dataset.createFromPipeline')
+
+ // Check third option (Connect Dataset)
+ const connectDataset = options[2]
+ expect(connectDataset).toHaveAttribute('href', '/datasets/connect')
+ expect(connectDataset).toHaveTextContent('dataset.connectDataset')
+ })
+})
diff --git a/web/app/components/datasets/settings/chunk-structure/index.spec.tsx b/web/app/components/datasets/settings/chunk-structure/index.spec.tsx
new file mode 100644
index 0000000000..878018408d
--- /dev/null
+++ b/web/app/components/datasets/settings/chunk-structure/index.spec.tsx
@@ -0,0 +1,85 @@
+import { render, screen } from '@testing-library/react'
+import { ChunkingMode } from '@/models/datasets'
+import ChunkStructure from './index'
+
+type MockOptionCardProps = {
+ id: string
+ title: string
+ isActive?: boolean
+ disabled?: boolean
+}
+
+// Mock dependencies
+vi.mock('../option-card', () => ({
+ default: ({ id, title, isActive, disabled }: MockOptionCardProps) => (
+
+ {title}
+
+ ),
+}))
+
+// Mock hook
+vi.mock('./hooks', () => ({
+ useChunkStructure: () => ({
+ options: [
+ {
+ id: ChunkingMode.text,
+ title: 'General',
+ description: 'General description',
+ icon: ,
+ effectColor: 'indigo',
+ iconActiveColor: 'indigo',
+ },
+ {
+ id: ChunkingMode.parentChild,
+ title: 'Parent-Child',
+ description: 'PC description',
+ icon: ,
+ effectColor: 'blue',
+ iconActiveColor: 'blue',
+ },
+ ],
+ }),
+}))
+
+describe('ChunkStructure', () => {
+ it('should render all options', () => {
+ render()
+
+ const options = screen.getAllByTestId('option-card')
+ expect(options).toHaveLength(2)
+ expect(options[0]).toHaveTextContent('General')
+ expect(options[1]).toHaveTextContent('Parent-Child')
+ })
+
+ it('should set active state correctly', () => {
+ // Render with 'text' active
+ const { unmount } = render()
+
+ const options = screen.getAllByTestId('option-card')
+ expect(options[0]).toHaveAttribute('data-active', 'true')
+ expect(options[1]).toHaveAttribute('data-active', 'false')
+
+ unmount()
+
+ // Render with 'parentChild' active
+ render()
+ const newOptions = screen.getAllByTestId('option-card')
+ expect(newOptions[0]).toHaveAttribute('data-active', 'false')
+ expect(newOptions[1]).toHaveAttribute('data-active', 'true')
+ })
+
+ it('should be always disabled', () => {
+ render()
+
+ const options = screen.getAllByTestId('option-card')
+ options.forEach((option) => {
+ expect(option).toHaveAttribute('data-disabled', 'true')
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx
new file mode 100644
index 0000000000..14ed18eb9a
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx
@@ -0,0 +1,130 @@
+import type { PluginDetail } from '@/app/components/plugins/types'
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ActionList from './action-list'
+
+// Mock dependencies
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) => {
+ if (options?.num !== undefined)
+ return `${options.num} ${options.action || 'actions'}`
+ return key
+ },
+ }),
+}))
+
+const mockToolData = [
+ { name: 'tool-1', label: { en_US: 'Tool 1' } },
+ { name: 'tool-2', label: { en_US: 'Tool 2' } },
+]
+
+const mockProvider = {
+ name: 'test-plugin/test-tool',
+ type: 'builtin',
+}
+
+vi.mock('@/service/use-tools', () => ({
+ useAllToolProviders: () => ({ data: [mockProvider] }),
+ useBuiltinTools: (key: string) => ({
+ data: key ? mockToolData : undefined,
+ }),
+}))
+
+vi.mock('@/app/components/tools/provider/tool-item', () => ({
+ default: ({ tool }: { tool: { name: string } }) => (
+ {tool.name}
+ ),
+}))
+
+const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({
+ id: 'test-id',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-02',
+ name: 'Test Plugin',
+ plugin_id: 'test-plugin',
+ plugin_unique_identifier: 'test-uid',
+ declaration: {
+ tool: {
+ identity: {
+ author: 'test-author',
+ name: 'test-tool',
+ description: { en_US: 'Test' },
+ icon: 'icon.png',
+ label: { en_US: 'Test Tool' },
+ tags: [],
+ },
+ credentials_schema: [],
+ },
+ } as unknown as PluginDetail['declaration'],
+ installation_id: 'install-1',
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-uid',
+ source: 'marketplace' as PluginDetail['source'],
+ meta: undefined,
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+ ...overrides,
+})
+
+describe('ActionList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render tool items when data is available', () => {
+ const detail = createPluginDetail()
+ render()
+
+ expect(screen.getByText('2 actions')).toBeInTheDocument()
+ expect(screen.getAllByTestId('tool-item')).toHaveLength(2)
+ })
+
+ it('should render tool names', () => {
+ const detail = createPluginDetail()
+ render()
+
+ expect(screen.getByText('tool-1')).toBeInTheDocument()
+ expect(screen.getByText('tool-2')).toBeInTheDocument()
+ })
+
+ it('should return null when no tool declaration', () => {
+ const detail = createPluginDetail({
+ declaration: {} as PluginDetail['declaration'],
+ })
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should return null when providerKey is empty', () => {
+ const detail = createPluginDetail({
+ declaration: {
+ tool: {
+ identity: undefined,
+ },
+ } as unknown as PluginDetail['declaration'],
+ })
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+ })
+
+ describe('Props', () => {
+ it('should use plugin_id in provider key construction', () => {
+ const detail = createPluginDetail()
+ render()
+
+ // The provider key is constructed from plugin_id and tool identity name
+ // When they match the mock, it renders
+ expect(screen.getByText('2 actions')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx
new file mode 100644
index 0000000000..b9b737c51b
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx
@@ -0,0 +1,131 @@
+import type { PluginDetail, StrategyDetail } from '@/app/components/plugins/types'
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import AgentStrategyList from './agent-strategy-list'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) => {
+ if (options?.num !== undefined)
+ return `${options.num} ${options.strategy || 'strategies'}`
+ return key
+ },
+ }),
+}))
+
+const mockStrategies = [
+ {
+ identity: {
+ author: 'author-1',
+ name: 'strategy-1',
+ icon: 'icon.png',
+ label: { en_US: 'Strategy 1' },
+ provider: 'provider-1',
+ },
+ parameters: [],
+ description: { en_US: 'Strategy 1 desc' },
+ output_schema: {},
+ features: [],
+ },
+] as unknown as StrategyDetail[]
+
+let mockStrategyProviderDetail: { declaration: { identity: unknown, strategies: StrategyDetail[] } } | undefined
+
+vi.mock('@/service/use-strategy', () => ({
+ useStrategyProviderDetail: () => ({
+ data: mockStrategyProviderDetail,
+ }),
+}))
+
+vi.mock('@/app/components/plugins/plugin-detail-panel/strategy-item', () => ({
+ default: ({ detail }: { detail: StrategyDetail }) => (
+ {detail.identity.name}
+ ),
+}))
+
+const createPluginDetail = (): PluginDetail => ({
+ id: 'test-id',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-02',
+ name: 'Test Plugin',
+ plugin_id: 'test-plugin',
+ plugin_unique_identifier: 'test-uid',
+ declaration: {
+ agent_strategy: {
+ identity: {
+ author: 'test-author',
+ name: 'test-strategy',
+ label: { en_US: 'Test Strategy' },
+ description: { en_US: 'Test' },
+ icon: 'icon.png',
+ tags: [],
+ },
+ },
+ } as PluginDetail['declaration'],
+ installation_id: 'install-1',
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-uid',
+ source: 'marketplace' as PluginDetail['source'],
+ meta: undefined,
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+})
+
+describe('AgentStrategyList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockStrategyProviderDetail = {
+ declaration: {
+ identity: { author: 'test', name: 'test' },
+ strategies: mockStrategies,
+ },
+ }
+ })
+
+ describe('Rendering', () => {
+ it('should render strategy items when data is available', () => {
+ render()
+
+ expect(screen.getByText('1 strategy')).toBeInTheDocument()
+ expect(screen.getByTestId('strategy-item')).toBeInTheDocument()
+ })
+
+ it('should return null when no strategy provider detail', () => {
+ mockStrategyProviderDetail = undefined
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should render multiple strategies', () => {
+ mockStrategyProviderDetail = {
+ declaration: {
+ identity: { author: 'test', name: 'test' },
+ strategies: [
+ ...mockStrategies,
+ { ...mockStrategies[0], identity: { ...mockStrategies[0].identity, name: 'strategy-2' } },
+ ],
+ },
+ }
+ render()
+
+ expect(screen.getByText('2 strategies')).toBeInTheDocument()
+ expect(screen.getAllByTestId('strategy-item')).toHaveLength(2)
+ })
+ })
+
+ describe('Props', () => {
+ it('should pass tenant_id to provider detail', () => {
+ const detail = createPluginDetail()
+ detail.tenant_id = 'custom-tenant'
+ render()
+
+ expect(screen.getByTestId('strategy-item')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx
new file mode 100644
index 0000000000..e315bbf62b
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx
@@ -0,0 +1,104 @@
+import type { PluginDetail } from '@/app/components/plugins/types'
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import DatasourceActionList from './datasource-action-list'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) => {
+ if (options?.num !== undefined)
+ return `${options.num} ${options.action || 'actions'}`
+ return key
+ },
+ }),
+}))
+
+const mockDataSourceList = [
+ { plugin_id: 'test-plugin', name: 'Data Source 1' },
+]
+
+let mockDataSourceListData: typeof mockDataSourceList | undefined
+
+vi.mock('@/service/use-pipeline', () => ({
+ useDataSourceList: () => ({ data: mockDataSourceListData }),
+}))
+
+vi.mock('@/app/components/workflow/block-selector/utils', () => ({
+ transformDataSourceToTool: (ds: unknown) => ds,
+}))
+
+const createPluginDetail = (): PluginDetail => ({
+ id: 'test-id',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-02',
+ name: 'Test Plugin',
+ plugin_id: 'test-plugin',
+ plugin_unique_identifier: 'test-uid',
+ declaration: {
+ datasource: {
+ identity: {
+ author: 'test-author',
+ name: 'test-datasource',
+ description: { en_US: 'Test' },
+ icon: 'icon.png',
+ label: { en_US: 'Test Datasource' },
+ tags: [],
+ },
+ credentials_schema: [],
+ },
+ } as unknown as PluginDetail['declaration'],
+ installation_id: 'install-1',
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-uid',
+ source: 'marketplace' as PluginDetail['source'],
+ meta: undefined,
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+})
+
+describe('DatasourceActionList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDataSourceListData = mockDataSourceList
+ })
+
+ describe('Rendering', () => {
+ it('should render action count when data and provider exist', () => {
+ render()
+
+ // The component always shows "0 action" because data is hardcoded as empty array
+ expect(screen.getByText('0 action')).toBeInTheDocument()
+ })
+
+ it('should return null when no provider found', () => {
+ mockDataSourceListData = []
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should return null when dataSourceList is undefined', () => {
+ mockDataSourceListData = undefined
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+ })
+
+ describe('Props', () => {
+ it('should use plugin_id to find matching datasource', () => {
+ const detail = createPluginDetail()
+ detail.plugin_id = 'different-plugin'
+ mockDataSourceListData = [{ plugin_id: 'different-plugin', name: 'Different DS' }]
+
+ render()
+
+ expect(screen.getByText('0 action')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx
new file mode 100644
index 0000000000..49c3ef1058
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx
@@ -0,0 +1,1002 @@
+import type { PluginDetail } from '../types'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import * as amplitude from '@/app/components/base/amplitude'
+import Toast from '@/app/components/base/toast'
+import { PluginSource } from '../types'
+import DetailHeader from './detail-header'
+
+// Use vi.hoisted for mock functions used in vi.mock factories
+const {
+ mockSetShowUpdatePluginModal,
+ mockRefreshModelProviders,
+ mockInvalidateAllToolProviders,
+ mockUninstallPlugin,
+ mockFetchReleases,
+ mockCheckForUpdates,
+} = vi.hoisted(() => {
+ return {
+ mockSetShowUpdatePluginModal: vi.fn(),
+ mockRefreshModelProviders: vi.fn(),
+ mockInvalidateAllToolProviders: vi.fn(),
+ mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })),
+ mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])),
+ mockCheckForUpdates: vi.fn(() => ({ needUpdate: true, toastProps: { type: 'success', message: 'Update available' } })),
+ }
+})
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('ahooks', async () => {
+ const React = await import('react')
+ return {
+ useBoolean: (initial: boolean) => {
+ const [value, setValue] = React.useState(initial)
+ return [
+ value,
+ {
+ setTrue: () => setValue(true),
+ setFalse: () => setValue(false),
+ },
+ ]
+ },
+ }
+})
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ userProfile: { timezone: 'UTC' },
+ }),
+}))
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({ theme: 'light' }),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useGetLanguage: () => 'en_US',
+ useLocale: () => 'en-US',
+}))
+
+// Global mock state for enable_marketplace
+let mockEnableMarketplace = true
+
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) =>
+ selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }),
+}))
+
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowUpdatePluginModal: mockSetShowUpdatePluginModal,
+ }),
+}))
+
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ refreshModelProviders: mockRefreshModelProviders,
+ }),
+}))
+
+vi.mock('@/service/plugins', () => ({
+ uninstallPlugin: mockUninstallPlugin,
+}))
+
+vi.mock('@/service/use-tools', () => ({
+ useAllToolProviders: () => ({ data: [] }),
+ useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
+}))
+
+vi.mock('../install-plugin/hooks', () => ({
+ useGitHubReleases: () => ({
+ checkForUpdates: mockCheckForUpdates,
+ fetchReleases: mockFetchReleases,
+ }),
+}))
+
+// Auto upgrade settings mock
+let mockAutoUpgradeInfo: {
+ strategy_setting: string
+ upgrade_mode: string
+ include_plugins: string[]
+ exclude_plugins: string[]
+ upgrade_time_of_day: number
+} | null = null
+
+vi.mock('../plugin-page/use-reference-setting', () => ({
+ default: () => ({
+ referenceSetting: mockAutoUpgradeInfo ? { auto_upgrade: mockAutoUpgradeInfo } : null,
+ }),
+}))
+
+vi.mock('../reference-setting-modal/auto-update-setting/types', () => ({
+ AUTO_UPDATE_MODE: {
+ update_all: 'update_all',
+ partial: 'partial',
+ exclude: 'exclude',
+ },
+}))
+
+vi.mock('../reference-setting-modal/auto-update-setting/utils', () => ({
+ convertUTCDaySecondsToLocalSeconds: (seconds: number) => seconds,
+ timeOfDayToDayjs: () => ({ format: () => '10:00 AM' }),
+}))
+
+vi.mock('@/hooks/use-i18n', () => ({
+ useRenderI18nObject: () => (obj: Record) => obj?.en_US || '',
+}))
+
+vi.mock('@/utils/classnames', () => ({
+ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
+}))
+
+vi.mock('@/utils/var', () => ({
+ getMarketplaceUrl: (path: string) => `https://marketplace.example.com${path}`,
+}))
+
+vi.mock('../card/base/card-icon', () => ({
+ default: ({ src }: { src: string }) => ,
+}))
+
+vi.mock('../card/base/description', () => ({
+ default: ({ text }: { text: string }) => {text}
,
+}))
+
+vi.mock('../card/base/org-info', () => ({
+ default: ({ orgName }: { orgName: string }) => {orgName}
,
+}))
+
+vi.mock('../card/base/title', () => ({
+ default: ({ title }: { title: string }) => {title}
,
+}))
+
+vi.mock('../base/badges/verified', () => ({
+ default: () => ,
+}))
+
+vi.mock('../base/deprecation-notice', () => ({
+ default: () => ,
+}))
+
+// Enhanced operation-dropdown mock
+vi.mock('./operation-dropdown', () => ({
+ default: ({ onInfo, onCheckVersion, onRemove }: { onInfo: () => void, onCheckVersion: () => void, onRemove: () => void }) => (
+
+
+
+
+
+ ),
+}))
+
+// Enhanced update modal mock
+vi.mock('../update-plugin/from-market-place', () => ({
+ default: ({ onSave, onCancel }: { onSave: () => void, onCancel: () => void }) => {
+ return (
+
+
+
+
+ )
+ },
+}))
+
+// Enhanced version picker mock
+vi.mock('../update-plugin/plugin-version-picker', () => ({
+ default: ({ trigger, onSelect, onShowChange }: { trigger: React.ReactNode, onSelect: (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void, onShowChange: (show: boolean) => void }) => (
+
+ {trigger}
+
+
+
+ ),
+}))
+
+vi.mock('../plugin-page/plugin-info', () => ({
+ default: ({ onHide }: { onHide: () => void }) => (
+
+
+
+ ),
+}))
+
+vi.mock('../plugin-auth', () => ({
+ AuthCategory: { tool: 'tool' },
+ PluginAuth: () => ,
+}))
+
+// Mock Confirm component
+vi.mock('@/app/components/base/confirm', () => ({
+ default: ({ isShow, onCancel, onConfirm, isLoading }: {
+ isShow: boolean
+ onCancel: () => void
+ onConfirm: () => void
+ isLoading: boolean
+ }) => isShow
+ ? (
+
+
+
+
+ )
+ : null,
+}))
+
+const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({
+ id: 'test-id',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-02',
+ name: 'Test Plugin',
+ plugin_id: 'test-plugin',
+ plugin_unique_identifier: 'test-uid',
+ declaration: {
+ author: 'test-author',
+ name: 'test-plugin-name',
+ category: 'tool',
+ label: { en_US: 'Test Plugin Label' },
+ description: { en_US: 'Test description' },
+ icon: 'icon.png',
+ verified: true,
+ tool: {
+ identity: {
+ name: 'test-tool',
+ author: 'author',
+ description: { en_US: 'Tool desc' },
+ icon: 'icon.png',
+ label: { en_US: 'Tool' },
+ tags: [],
+ },
+ credentials_schema: [],
+ },
+ } as unknown as PluginDetail['declaration'],
+ installation_id: 'install-1',
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-uid',
+ source: PluginSource.marketplace,
+ meta: undefined,
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+ ...overrides,
+})
+
+describe('DetailHeader', () => {
+ const mockOnUpdate = vi.fn()
+ const mockOnHide = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockAutoUpgradeInfo = null
+ mockEnableMarketplace = true
+ vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
+ vi.spyOn(amplitude, 'trackEvent').mockImplementation(() => {})
+ })
+
+ describe('Rendering', () => {
+ it('should render plugin title', () => {
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should render plugin icon with correct src', () => {
+ render()
+
+ expect(screen.getByTestId('card-icon')).toBeInTheDocument()
+ })
+
+ it('should render icon with http url directly', () => {
+ const detail = createPluginDetail({
+ declaration: {
+ ...createPluginDetail().declaration,
+ icon: 'https://example.com/icon.png',
+ } as unknown as PluginDetail['declaration'],
+ })
+ render()
+
+ expect(screen.getByTestId('card-icon')).toHaveAttribute('data-src', 'https://example.com/icon.png')
+ })
+
+ it('should render description when not in readme view', () => {
+ render()
+
+ expect(screen.getByTestId('description')).toBeInTheDocument()
+ })
+
+ it('should not render description in readme view', () => {
+ render()
+
+ expect(screen.queryByTestId('description')).not.toBeInTheDocument()
+ })
+
+ it('should render verified badge when verified', () => {
+ render()
+
+ expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
+ })
+ })
+
+ describe('Version Display', () => {
+ it('should show new version indicator when hasNewVersion is true', () => {
+ const detail = createPluginDetail({
+ version: '1.0.0',
+ latest_version: '2.0.0',
+ })
+ render()
+
+ // Badge component should render with the version
+ expect(screen.getByText('1.0.0')).toBeInTheDocument()
+ })
+
+ it('should not show new version indicator when versions match', () => {
+ const detail = createPluginDetail({
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ })
+ render()
+
+ // Badge component should render with the version
+ expect(screen.getByText('1.0.0')).toBeInTheDocument()
+ })
+
+ it('should show update button when new version is available', () => {
+ const detail = createPluginDetail({
+ version: '1.0.0',
+ latest_version: '2.0.0',
+ })
+ render()
+
+ expect(screen.getByText('detailPanel.operation.update')).toBeInTheDocument()
+ })
+
+ it('should show update button for GitHub source', () => {
+ const detail = createPluginDetail({
+ source: PluginSource.github,
+ meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
+ })
+ render()
+
+ expect(screen.getByText('detailPanel.operation.update')).toBeInTheDocument()
+ })
+ })
+
+ describe('Auto Upgrade Feature', () => {
+ it('should render component when marketplace is disabled', () => {
+ mockAutoUpgradeInfo = {
+ strategy_setting: 'enabled',
+ upgrade_mode: 'update_all',
+ include_plugins: [],
+ exclude_plugins: [],
+ upgrade_time_of_day: 36000,
+ }
+
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should render component when strategy is disabled', () => {
+ mockAutoUpgradeInfo = {
+ strategy_setting: 'disabled',
+ upgrade_mode: 'update_all',
+ include_plugins: [],
+ exclude_plugins: [],
+ upgrade_time_of_day: 36000,
+ }
+
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should enable auto upgrade for update_all mode', () => {
+ mockAutoUpgradeInfo = {
+ strategy_setting: 'enabled',
+ upgrade_mode: 'update_all',
+ include_plugins: [],
+ exclude_plugins: [],
+ upgrade_time_of_day: 36000,
+ }
+
+ render()
+
+ // Auto upgrade badge should be rendered
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should enable auto upgrade for partial mode when plugin is included', () => {
+ mockAutoUpgradeInfo = {
+ strategy_setting: 'enabled',
+ upgrade_mode: 'partial',
+ include_plugins: ['test-plugin'],
+ exclude_plugins: [],
+ upgrade_time_of_day: 36000,
+ }
+
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should not enable auto upgrade for partial mode when plugin is not included', () => {
+ mockAutoUpgradeInfo = {
+ strategy_setting: 'enabled',
+ upgrade_mode: 'partial',
+ include_plugins: ['other-plugin'],
+ exclude_plugins: [],
+ upgrade_time_of_day: 36000,
+ }
+
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should enable auto upgrade for exclude mode when plugin is not excluded', () => {
+ mockAutoUpgradeInfo = {
+ strategy_setting: 'enabled',
+ upgrade_mode: 'exclude',
+ include_plugins: [],
+ exclude_plugins: ['other-plugin'],
+ upgrade_time_of_day: 36000,
+ }
+
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should not enable auto upgrade for exclude mode when plugin is excluded', () => {
+ mockAutoUpgradeInfo = {
+ strategy_setting: 'enabled',
+ upgrade_mode: 'exclude',
+ include_plugins: [],
+ exclude_plugins: ['test-plugin'],
+ upgrade_time_of_day: 36000,
+ }
+
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should not enable auto upgrade for non-marketplace plugins', () => {
+ mockAutoUpgradeInfo = {
+ strategy_setting: 'enabled',
+ upgrade_mode: 'update_all',
+ include_plugins: [],
+ exclude_plugins: [],
+ upgrade_time_of_day: 36000,
+ }
+
+ const detail = createPluginDetail({
+ source: PluginSource.github,
+ meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
+ })
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should not enable auto upgrade when marketplace feature is disabled', () => {
+ mockEnableMarketplace = false
+ mockAutoUpgradeInfo = {
+ strategy_setting: 'enabled',
+ upgrade_mode: 'update_all',
+ include_plugins: [],
+ exclude_plugins: [],
+ upgrade_time_of_day: 36000,
+ }
+
+ render()
+
+ // Component should still render but auto upgrade should be disabled
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onHide when close button clicked', () => {
+ render()
+
+ // Find the close button (ActionButton with action-btn class)
+ const actionButtons = screen.getAllByRole('button').filter(btn => btn.classList.contains('action-btn'))
+ fireEvent.click(actionButtons[actionButtons.length - 1])
+
+ expect(mockOnHide).toHaveBeenCalled()
+ })
+
+ it('should have info button available', () => {
+ render()
+
+ const infoBtn = screen.getByTestId('info-btn')
+ fireEvent.click(infoBtn)
+
+ expect(infoBtn).toBeInTheDocument()
+ })
+
+ it('should have check version button available', () => {
+ render()
+
+ const checkBtn = screen.getByTestId('check-version-btn')
+ fireEvent.click(checkBtn)
+
+ expect(checkBtn).toBeInTheDocument()
+ })
+ })
+
+ describe('Update Flow - Marketplace', () => {
+ it('should have update button for new version', () => {
+ const detail = createPluginDetail({
+ version: '1.0.0',
+ latest_version: '2.0.0',
+ })
+ render()
+
+ const updateBtn = screen.getByText('detailPanel.operation.update')
+ fireEvent.click(updateBtn)
+
+ expect(updateBtn).toBeInTheDocument()
+ })
+
+ it('should have version picker select button', () => {
+ render()
+
+ const selectBtn = screen.getByTestId('select-version-btn')
+ fireEvent.click(selectBtn)
+
+ expect(selectBtn).toBeInTheDocument()
+ })
+
+ it('should have downgrade button', () => {
+ render()
+
+ const downgradeBtn = screen.getByTestId('select-downgrade-btn')
+ fireEvent.click(downgradeBtn)
+
+ expect(downgradeBtn).toBeInTheDocument()
+ })
+ })
+
+ describe('Update Flow - GitHub', () => {
+ it('should check for updates from GitHub when update clicked', async () => {
+ const detail = createPluginDetail({
+ source: PluginSource.github,
+ meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
+ })
+ render()
+
+ fireEvent.click(screen.getByText('detailPanel.operation.update'))
+
+ await waitFor(() => {
+ expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo')
+ })
+ })
+
+ it('should show toast when no releases found', async () => {
+ mockFetchReleases.mockResolvedValueOnce([])
+
+ const detail = createPluginDetail({
+ source: PluginSource.github,
+ meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
+ })
+ render()
+
+ fireEvent.click(screen.getByText('detailPanel.operation.update'))
+
+ await waitFor(() => {
+ expect(mockFetchReleases).toHaveBeenCalled()
+ })
+ })
+
+ it('should show update plugin modal when update is needed', async () => {
+ const detail = createPluginDetail({
+ source: PluginSource.github,
+ meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
+ })
+ render()
+
+ fireEvent.click(screen.getByText('detailPanel.operation.update'))
+
+ await waitFor(() => {
+ expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onUpdate via onSaveCallback when GitHub update completes', async () => {
+ mockSetShowUpdatePluginModal.mockImplementation(({ onSaveCallback }) => {
+ // Simulate the modal completing and calling onSaveCallback
+ onSaveCallback()
+ })
+
+ const detail = createPluginDetail({
+ source: PluginSource.github,
+ meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
+ })
+ render()
+
+ fireEvent.click(screen.getByText('detailPanel.operation.update'))
+
+ await waitFor(() => {
+ expect(mockOnUpdate).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('Delete Flow', () => {
+ it('should have remove button available', () => {
+ render()
+
+ const removeBtn = screen.getByTestId('remove-btn')
+ fireEvent.click(removeBtn)
+
+ expect(removeBtn).toBeInTheDocument()
+ })
+
+ it('should have uninstallPlugin mock defined', () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('remove-btn'))
+
+ expect(mockUninstallPlugin).toBeDefined()
+ })
+
+ it('should render correctly for model plugin delete', () => {
+ const detail = createPluginDetail({
+ declaration: {
+ ...createPluginDetail().declaration,
+ category: 'model',
+ } as unknown as PluginDetail['declaration'],
+ })
+ render()
+
+ expect(screen.getByTestId('remove-btn')).toBeInTheDocument()
+ })
+
+ it('should render correctly for tool plugin delete', () => {
+ render()
+
+ expect(screen.getByTestId('remove-btn')).toBeInTheDocument()
+ })
+ })
+
+ describe('Plugin Sources', () => {
+ it('should render github source icon', () => {
+ const detail = createPluginDetail({
+ source: PluginSource.github,
+ meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
+ })
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should render local source icon', () => {
+ const detail = createPluginDetail({ source: PluginSource.local })
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should render debugging source icon', () => {
+ const detail = createPluginDetail({ source: PluginSource.debugging })
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should not render deprecation notice for non-marketplace source', () => {
+ const detail = createPluginDetail({ source: PluginSource.github, meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' } })
+ render()
+
+ expect(screen.queryByTestId('deprecation-notice')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Detail URL Generation', () => {
+ it('should render GitHub source correctly', () => {
+ const detail = createPluginDetail({
+ source: PluginSource.github,
+ meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
+ })
+ render()
+
+ expect(screen.getByTestId('operation-dropdown')).toBeInTheDocument()
+ })
+
+ it('should render marketplace source correctly', () => {
+ render()
+
+ expect(screen.getByTestId('operation-dropdown')).toBeInTheDocument()
+ })
+
+ it('should render local source correctly', () => {
+ const detail = createPluginDetail({ source: PluginSource.local })
+ render()
+
+ expect(screen.getByTestId('operation-dropdown')).toBeInTheDocument()
+ })
+ })
+
+ describe('Plugin Auth', () => {
+ it('should render plugin auth for tool category', () => {
+ render()
+
+ expect(screen.getByTestId('plugin-auth')).toBeInTheDocument()
+ })
+
+ it('should not render plugin auth for non-tool category', () => {
+ const detail = createPluginDetail({
+ declaration: {
+ ...createPluginDetail().declaration,
+ category: 'model',
+ } as unknown as PluginDetail['declaration'],
+ })
+ render()
+
+ expect(screen.queryByTestId('plugin-auth')).not.toBeInTheDocument()
+ })
+
+ it('should not render plugin auth in readme view', () => {
+ render()
+
+ expect(screen.queryByTestId('plugin-auth')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle plugin without version', () => {
+ const detail = createPluginDetail({ version: '' })
+ render()
+
+ expect(screen.getByTestId('title')).toBeInTheDocument()
+ })
+
+ it('should handle plugin with name containing slash', () => {
+ const detail = createPluginDetail({
+ declaration: {
+ ...createPluginDetail().declaration,
+ name: 'org/plugin-name',
+ } as unknown as PluginDetail['declaration'],
+ })
+ render()
+
+ expect(screen.getByTestId('org-info')).toBeInTheDocument()
+ })
+
+ it('should handle empty icon', () => {
+ const detail = createPluginDetail({
+ declaration: {
+ ...createPluginDetail().declaration,
+ icon: '',
+ } as unknown as PluginDetail['declaration'],
+ })
+ render()
+
+ expect(screen.getByTestId('card-icon')).toHaveAttribute('data-src', '')
+ })
+ })
+
+ describe('Delete Confirmation Flow', () => {
+ it('should show delete confirm when remove button is clicked', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('remove-btn'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
+ })
+ })
+
+ it('should hide delete confirm when cancel is clicked', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('remove-btn'))
+ await waitFor(() => {
+ expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('confirm-cancel'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should call uninstallPlugin when confirm delete is clicked', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('remove-btn'))
+ await waitFor(() => {
+ expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ await waitFor(() => {
+ expect(mockUninstallPlugin).toHaveBeenCalledWith('test-id')
+ })
+ })
+
+ it('should call onUpdate with true after successful delete', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('remove-btn'))
+ await waitFor(() => {
+ expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ await waitFor(() => {
+ expect(mockOnUpdate).toHaveBeenCalledWith(true)
+ })
+ })
+
+ it('should refresh model providers when deleting model plugin', async () => {
+ const detail = createPluginDetail({
+ declaration: {
+ ...createPluginDetail().declaration,
+ category: 'model',
+ } as unknown as PluginDetail['declaration'],
+ })
+ render()
+
+ fireEvent.click(screen.getByTestId('remove-btn'))
+ await waitFor(() => {
+ expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ await waitFor(() => {
+ expect(mockRefreshModelProviders).toHaveBeenCalled()
+ })
+ })
+
+ it('should invalidate tool providers when deleting tool plugin', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('remove-btn'))
+ await waitFor(() => {
+ expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ await waitFor(() => {
+ expect(mockInvalidateAllToolProviders).toHaveBeenCalled()
+ })
+ })
+
+ it('should track plugin uninstalled event after successful delete', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('remove-btn'))
+ await waitFor(() => {
+ expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('confirm-ok'))
+
+ await waitFor(() => {
+ expect(amplitude.trackEvent).toHaveBeenCalledWith('plugin_uninstalled', expect.any(Object))
+ })
+ })
+ })
+
+ describe('Update Modal Flow', () => {
+ it('should show update modal when update button clicked for marketplace plugin', async () => {
+ const detail = createPluginDetail({
+ version: '1.0.0',
+ latest_version: '2.0.0',
+ })
+ render()
+
+ fireEvent.click(screen.getByText('detailPanel.operation.update'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('update-modal')).toBeInTheDocument()
+ })
+ })
+
+ it('should call onUpdate when save is clicked in update modal', async () => {
+ const detail = createPluginDetail({
+ version: '1.0.0',
+ latest_version: '2.0.0',
+ })
+ render()
+
+ fireEvent.click(screen.getByText('detailPanel.operation.update'))
+ await waitFor(() => {
+ expect(screen.getByTestId('update-modal')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('update-modal-save'))
+
+ await waitFor(() => {
+ expect(mockOnUpdate).toHaveBeenCalled()
+ })
+ })
+
+ it('should hide update modal when cancel is clicked', async () => {
+ const detail = createPluginDetail({
+ version: '1.0.0',
+ latest_version: '2.0.0',
+ })
+ render()
+
+ fireEvent.click(screen.getByText('detailPanel.operation.update'))
+ await waitFor(() => {
+ expect(screen.getByTestId('update-modal')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('update-modal-cancel'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('update-modal')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Plugin Info Modal', () => {
+ it('should show plugin info modal when info button is clicked', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('info-btn'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
+ })
+ })
+
+ it('should hide plugin info modal when close button is clicked', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('info-btn'))
+ await waitFor(() => {
+ expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
+ })
+
+ fireEvent.click(screen.getByTestId('plugin-info-close'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('plugin-info')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should render plugin info with GitHub meta data', () => {
+ const detail = createPluginDetail({
+ source: PluginSource.github,
+ meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'test-pkg' },
+ })
+ render()
+
+ expect(screen.getByTestId('info-btn')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx
new file mode 100644
index 0000000000..203bd6a02a
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx
@@ -0,0 +1,386 @@
+import type { EndpointListItem, PluginDetail } from '../types'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import Toast from '@/app/components/base/toast'
+import EndpointCard from './endpoint-card'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('copy-to-clipboard', () => ({
+ default: vi.fn(),
+}))
+
+const mockHandleChange = vi.fn()
+const mockEnableEndpoint = vi.fn()
+const mockDisableEndpoint = vi.fn()
+const mockDeleteEndpoint = vi.fn()
+const mockUpdateEndpoint = vi.fn()
+
+// Flags to control whether operations should fail
+const failureFlags = {
+ enable: false,
+ disable: false,
+ delete: false,
+ update: false,
+}
+
+vi.mock('@/service/use-endpoints', () => ({
+ useEnableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
+ mutate: (id: string) => {
+ mockEnableEndpoint(id)
+ if (failureFlags.enable)
+ onError()
+ else
+ onSuccess()
+ },
+ }),
+ useDisableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
+ mutate: (id: string) => {
+ mockDisableEndpoint(id)
+ if (failureFlags.disable)
+ onError()
+ else
+ onSuccess()
+ },
+ }),
+ useDeleteEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
+ mutate: (id: string) => {
+ mockDeleteEndpoint(id)
+ if (failureFlags.delete)
+ onError()
+ else
+ onSuccess()
+ },
+ }),
+ useUpdateEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
+ mutate: (data: unknown) => {
+ mockUpdateEndpoint(data)
+ if (failureFlags.update)
+ onError()
+ else
+ onSuccess()
+ },
+ }),
+}))
+
+vi.mock('@/app/components/header/indicator', () => ({
+ default: ({ color }: { color: string }) => ,
+}))
+
+vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
+ toolCredentialToFormSchemas: (schemas: unknown[]) => schemas,
+ addDefaultValue: (value: unknown) => value,
+}))
+
+vi.mock('./endpoint-modal', () => ({
+ default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => (
+
+
+
+
+ ),
+}))
+
+const mockEndpointData: EndpointListItem = {
+ id: 'ep-1',
+ name: 'Test Endpoint',
+ url: 'https://api.example.com',
+ enabled: true,
+ created_at: '2024-01-01',
+ updated_at: '2024-01-02',
+ settings: {},
+ tenant_id: 'tenant-1',
+ plugin_id: 'plugin-1',
+ expired_at: '',
+ hook_id: 'hook-1',
+ declaration: {
+ settings: [],
+ endpoints: [
+ { path: '/api/test', method: 'GET' },
+ { path: '/api/hidden', method: 'POST', hidden: true },
+ ],
+ },
+}
+
+const mockPluginDetail: PluginDetail = {
+ id: 'test-id',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-02',
+ name: 'Test Plugin',
+ plugin_id: 'test-plugin',
+ plugin_unique_identifier: 'test-uid',
+ declaration: {} as PluginDetail['declaration'],
+ installation_id: 'install-1',
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-uid',
+ source: 'marketplace' as PluginDetail['source'],
+ meta: undefined,
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+}
+
+describe('EndpointCard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ // Reset failure flags
+ failureFlags.enable = false
+ failureFlags.disable = false
+ failureFlags.delete = false
+ failureFlags.update = false
+ // Mock Toast.notify to prevent toast elements from accumulating in DOM
+ vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ describe('Rendering', () => {
+ it('should render endpoint name', () => {
+ render()
+
+ expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
+ })
+
+ it('should render visible endpoints only', () => {
+ render()
+
+ expect(screen.getByText('GET')).toBeInTheDocument()
+ expect(screen.getByText('https://api.example.com/api/test')).toBeInTheDocument()
+ expect(screen.queryByText('POST')).not.toBeInTheDocument()
+ })
+
+ it('should show active status when enabled', () => {
+ render()
+
+ expect(screen.getByText('detailPanel.serviceOk')).toBeInTheDocument()
+ expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
+ })
+
+ it('should show disabled status when not enabled', () => {
+ const disabledData = { ...mockEndpointData, enabled: false }
+ render()
+
+ expect(screen.getByText('detailPanel.disabled')).toBeInTheDocument()
+ expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'gray')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should show disable confirm when switching off', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('switch'))
+
+ expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument()
+ })
+
+ it('should call disableEndpoint when confirm disable', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('switch'))
+ // Click confirm button in the Confirm dialog
+ fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
+
+ expect(mockDisableEndpoint).toHaveBeenCalledWith('ep-1')
+ })
+
+ it('should show delete confirm when delete clicked', () => {
+ render()
+
+ // Find delete button by its destructive class
+ const allButtons = screen.getAllByRole('button')
+ const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
+ expect(deleteButton).toBeDefined()
+ if (deleteButton)
+ fireEvent.click(deleteButton)
+
+ expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument()
+ })
+
+ it('should call deleteEndpoint when confirm delete', () => {
+ render()
+
+ const allButtons = screen.getAllByRole('button')
+ const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
+ expect(deleteButton).toBeDefined()
+ if (deleteButton)
+ fireEvent.click(deleteButton)
+ fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
+
+ expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1')
+ })
+
+ it('should show edit modal when edit clicked', () => {
+ render()
+
+ const actionButtons = screen.getAllByRole('button', { name: '' })
+ const editButton = actionButtons[0]
+ if (editButton)
+ fireEvent.click(editButton)
+
+ expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
+ })
+
+ it('should call updateEndpoint when save in modal', () => {
+ render()
+
+ const actionButtons = screen.getAllByRole('button', { name: '' })
+ const editButton = actionButtons[0]
+ if (editButton)
+ fireEvent.click(editButton)
+ fireEvent.click(screen.getByTestId('modal-save'))
+
+ expect(mockUpdateEndpoint).toHaveBeenCalled()
+ })
+ })
+
+ describe('Copy Functionality', () => {
+ it('should reset copy state after timeout', async () => {
+ render()
+
+ // Find copy button by its class
+ const allButtons = screen.getAllByRole('button')
+ const copyButton = allButtons.find(btn => btn.classList.contains('ml-2'))
+ expect(copyButton).toBeDefined()
+ if (copyButton) {
+ fireEvent.click(copyButton)
+
+ act(() => {
+ vi.advanceTimersByTime(2000)
+ })
+
+ // After timeout, the component should still be rendered correctly
+ expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
+ }
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty endpoints', () => {
+ const dataWithNoEndpoints = {
+ ...mockEndpointData,
+ declaration: { settings: [], endpoints: [] },
+ }
+ render()
+
+ expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
+ })
+
+ it('should call handleChange after enable', () => {
+ const disabledData = { ...mockEndpointData, enabled: false }
+ render()
+
+ fireEvent.click(screen.getByRole('switch'))
+
+ expect(mockHandleChange).toHaveBeenCalled()
+ })
+
+ it('should hide disable confirm and revert state when cancel clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('switch'))
+ expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
+
+ // Confirm should be hidden
+ expect(screen.queryByText('detailPanel.endpointDisableTip')).not.toBeInTheDocument()
+ })
+
+ it('should hide delete confirm when cancel clicked', () => {
+ render()
+
+ const allButtons = screen.getAllByRole('button')
+ const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
+ expect(deleteButton).toBeDefined()
+ if (deleteButton)
+ fireEvent.click(deleteButton)
+ expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
+
+ expect(screen.queryByText('detailPanel.endpointDeleteTip')).not.toBeInTheDocument()
+ })
+
+ it('should hide edit modal when cancel clicked', () => {
+ render()
+
+ const actionButtons = screen.getAllByRole('button', { name: '' })
+ const editButton = actionButtons[0]
+ if (editButton)
+ fireEvent.click(editButton)
+ expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('modal-cancel'))
+
+ expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Error Handling', () => {
+ it('should show error toast when enable fails', () => {
+ failureFlags.enable = true
+ const disabledData = { ...mockEndpointData, enabled: false }
+ render()
+
+ fireEvent.click(screen.getByRole('switch'))
+
+ expect(mockEnableEndpoint).toHaveBeenCalled()
+ })
+
+ it('should show error toast when disable fails', () => {
+ failureFlags.disable = true
+ render()
+
+ fireEvent.click(screen.getByRole('switch'))
+ fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
+
+ expect(mockDisableEndpoint).toHaveBeenCalled()
+ })
+
+ it('should show error toast when delete fails', () => {
+ failureFlags.delete = true
+ render()
+
+ const allButtons = screen.getAllByRole('button')
+ const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
+ if (deleteButton)
+ fireEvent.click(deleteButton)
+ fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
+
+ expect(mockDeleteEndpoint).toHaveBeenCalled()
+ })
+
+ it('should show error toast when update fails', () => {
+ render()
+
+ const actionButtons = screen.getAllByRole('button', { name: '' })
+ const editButton = actionButtons[0]
+ expect(editButton).toBeDefined()
+ if (editButton)
+ fireEvent.click(editButton)
+
+ // Verify modal is open
+ expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
+
+ // Set failure flag before save is clicked
+ failureFlags.update = true
+ fireEvent.click(screen.getByTestId('modal-save'))
+
+ expect(mockUpdateEndpoint).toHaveBeenCalled()
+ // On error, handleChange is not called
+ expect(mockHandleChange).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx
new file mode 100644
index 0000000000..0c9865153a
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx
@@ -0,0 +1,222 @@
+import type { PluginDetail } from '@/app/components/plugins/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import EndpointList from './endpoint-list'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.example.com${path}`,
+}))
+
+vi.mock('@/utils/classnames', () => ({
+ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
+}))
+
+const mockEndpoints = [
+ { id: 'ep-1', name: 'Endpoint 1', url: 'https://api.example.com', declaration: { settings: [], endpoints: [] } },
+]
+
+let mockEndpointListData: { endpoints: typeof mockEndpoints } | undefined
+
+const mockInvalidateEndpointList = vi.fn()
+const mockCreateEndpoint = vi.fn()
+
+vi.mock('@/service/use-endpoints', () => ({
+ useEndpointList: () => ({ data: mockEndpointListData }),
+ useInvalidateEndpointList: () => mockInvalidateEndpointList,
+ useCreateEndpoint: ({ onSuccess }: { onSuccess: () => void }) => ({
+ mutate: (data: unknown) => {
+ mockCreateEndpoint(data)
+ onSuccess()
+ },
+ }),
+}))
+
+vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
+ toolCredentialToFormSchemas: (schemas: unknown[]) => schemas,
+}))
+
+vi.mock('./endpoint-card', () => ({
+ default: ({ data }: { data: { name: string } }) => (
+ {data.name}
+ ),
+}))
+
+vi.mock('./endpoint-modal', () => ({
+ default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => (
+
+
+
+
+ ),
+}))
+
+const createPluginDetail = (): PluginDetail => ({
+ id: 'test-id',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-02',
+ name: 'Test Plugin',
+ plugin_id: 'test-plugin',
+ plugin_unique_identifier: 'test-uid',
+ declaration: {
+ endpoint: { settings: [], endpoints: [] },
+ tool: undefined,
+ } as unknown as PluginDetail['declaration'],
+ installation_id: 'install-1',
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-uid',
+ source: 'marketplace' as PluginDetail['source'],
+ meta: undefined,
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+})
+
+describe('EndpointList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockEndpointListData = { endpoints: mockEndpoints }
+ })
+
+ describe('Rendering', () => {
+ it('should render endpoint list', () => {
+ render()
+
+ expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument()
+ })
+
+ it('should render endpoint cards', () => {
+ render()
+
+ expect(screen.getByTestId('endpoint-card')).toBeInTheDocument()
+ expect(screen.getByText('Endpoint 1')).toBeInTheDocument()
+ })
+
+ it('should return null when no data', () => {
+ mockEndpointListData = undefined
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should show empty message when no endpoints', () => {
+ mockEndpointListData = { endpoints: [] }
+ render()
+
+ expect(screen.getByText('detailPanel.endpointsEmpty')).toBeInTheDocument()
+ })
+
+ it('should render add button', () => {
+ render()
+
+ const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
+ expect(addButton).toBeDefined()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should show modal when add button clicked', () => {
+ render()
+
+ const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
+ if (addButton)
+ fireEvent.click(addButton)
+
+ expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
+ })
+
+ it('should hide modal when cancel clicked', () => {
+ render()
+
+ const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
+ if (addButton)
+ fireEvent.click(addButton)
+ expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('modal-cancel'))
+ expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument()
+ })
+
+ it('should call createEndpoint when save clicked', () => {
+ render()
+
+ const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
+ if (addButton)
+ fireEvent.click(addButton)
+ fireEvent.click(screen.getByTestId('modal-save'))
+
+ expect(mockCreateEndpoint).toHaveBeenCalled()
+ })
+ })
+
+ describe('Border Style', () => {
+ it('should render with border style based on tool existence', () => {
+ const detail = createPluginDetail()
+ detail.declaration.tool = {} as PluginDetail['declaration']['tool']
+ render()
+
+ // Verify the component renders correctly
+ expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument()
+ })
+ })
+
+ describe('Multiple Endpoints', () => {
+ it('should render multiple endpoint cards', () => {
+ mockEndpointListData = {
+ endpoints: [
+ { id: 'ep-1', name: 'Endpoint 1', url: 'https://api1.example.com', declaration: { settings: [], endpoints: [] } },
+ { id: 'ep-2', name: 'Endpoint 2', url: 'https://api2.example.com', declaration: { settings: [], endpoints: [] } },
+ ],
+ }
+ render()
+
+ expect(screen.getAllByTestId('endpoint-card')).toHaveLength(2)
+ })
+ })
+
+ describe('Tooltip', () => {
+ it('should render with tooltip content', () => {
+ render()
+
+ // Tooltip is rendered - the add button should be visible
+ const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
+ expect(addButton).toBeDefined()
+ })
+ })
+
+ describe('Create Endpoint Flow', () => {
+ it('should invalidate endpoint list after successful create', () => {
+ render()
+
+ const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
+ if (addButton)
+ fireEvent.click(addButton)
+ fireEvent.click(screen.getByTestId('modal-save'))
+
+ expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin')
+ })
+
+ it('should pass correct params to createEndpoint', () => {
+ render()
+
+ const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
+ if (addButton)
+ fireEvent.click(addButton)
+ fireEvent.click(screen.getByTestId('modal-save'))
+
+ expect(mockCreateEndpoint).toHaveBeenCalledWith({
+ pluginUniqueID: 'test-uid',
+ state: { name: 'New Endpoint' },
+ })
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx
new file mode 100644
index 0000000000..96fa647e91
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx
@@ -0,0 +1,519 @@
+import type { FormSchema } from '../../base/form/types'
+import type { PluginDetail } from '../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import Toast from '@/app/components/base/toast'
+import EndpointModal from './endpoint-modal'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, opts?: Record) => {
+ if (opts?.field)
+ return `${key}: ${opts.field}`
+ return key
+ },
+ }),
+}))
+
+vi.mock('@/hooks/use-i18n', () => ({
+ useRenderI18nObject: () => (obj: Record | string) =>
+ typeof obj === 'string' ? obj : obj?.en_US || '',
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
+ default: ({ value, onChange, fieldMoreInfo }: {
+ value: Record
+ onChange: (v: Record) => void
+ fieldMoreInfo?: (item: { url?: string }) => React.ReactNode
+ }) => {
+ return (
+
+
onChange({ ...value, name: e.target.value })}
+ />
+ {/* Render fieldMoreInfo to test url link */}
+ {fieldMoreInfo && (
+
+ {fieldMoreInfo({ url: 'https://example.com' })}
+ {fieldMoreInfo({})}
+
+ )}
+
+ )
+ },
+}))
+
+vi.mock('../readme-panel/entrance', () => ({
+ ReadmeEntrance: () => ,
+}))
+
+const mockFormSchemas = [
+ { name: 'name', label: { en_US: 'Name' }, type: 'text-input', required: true, default: '' },
+ { name: 'apiKey', label: { en_US: 'API Key' }, type: 'secret-input', required: false, default: '' },
+] as unknown as FormSchema[]
+
+const mockPluginDetail: PluginDetail = {
+ id: 'test-id',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-02',
+ name: 'Test Plugin',
+ plugin_id: 'test-plugin',
+ plugin_unique_identifier: 'test-uid',
+ declaration: {} as PluginDetail['declaration'],
+ installation_id: 'install-1',
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-uid',
+ source: 'marketplace' as PluginDetail['source'],
+ meta: undefined,
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+}
+
+describe('EndpointModal', () => {
+ const mockOnCancel = vi.fn()
+ const mockOnSaved = vi.fn()
+ let mockToastNotify: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockToastNotify = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
+ })
+
+ describe('Rendering', () => {
+ it('should render drawer', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('should render title and description', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('detailPanel.endpointModalTitle')).toBeInTheDocument()
+ expect(screen.getByText('detailPanel.endpointModalDesc')).toBeInTheDocument()
+ })
+
+ it('should render form with fieldMoreInfo url link', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('field-more-info')).toBeInTheDocument()
+ // Should render the "howToGet" link when url exists
+ expect(screen.getByText('howToGet')).toBeInTheDocument()
+ })
+
+ it('should render readme entrance', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onCancel when cancel clicked', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
+
+ expect(mockOnCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onCancel when close button clicked', () => {
+ render(
+ ,
+ )
+
+ // Find the close button (ActionButton with RiCloseLine icon)
+ const allButtons = screen.getAllByRole('button')
+ const closeButton = allButtons.find(btn => btn.classList.contains('action-btn'))
+ if (closeButton)
+ fireEvent.click(closeButton)
+
+ expect(mockOnCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should update form value when input changes', () => {
+ render(
+ ,
+ )
+
+ const input = screen.getByTestId('form-input')
+ fireEvent.change(input, { target: { value: 'Test Name' } })
+
+ expect(input).toHaveValue('Test Name')
+ })
+ })
+
+ describe('Default Values', () => {
+ it('should use defaultValues when provided', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('form-input')).toHaveValue('Default Name')
+ })
+
+ it('should extract default values from schemas when no defaultValues', () => {
+ const schemasWithDefaults = [
+ { name: 'name', label: 'Name', type: 'text-input', required: true, default: 'Schema Default' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('form-input')).toHaveValue('Schema Default')
+ })
+
+ it('should handle schemas without default values', () => {
+ const schemasNoDefault = [
+ { name: 'name', label: 'Name', type: 'text-input', required: false },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('form')).toBeInTheDocument()
+ })
+ })
+
+ describe('Validation - handleSave', () => {
+ it('should show toast error when required field is empty', () => {
+ const schemasWithRequired = [
+ { name: 'name', label: { en_US: 'Name Field' }, type: 'text-input', required: true, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: expect.stringContaining('errorMsg.fieldRequired'),
+ })
+ expect(mockOnSaved).not.toHaveBeenCalled()
+ })
+
+ it('should show toast error with string label when required field is empty', () => {
+ const schemasWithStringLabel = [
+ { name: 'name', label: 'String Label', type: 'text-input', required: true, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: expect.stringContaining('String Label'),
+ })
+ })
+
+ it('should call onSaved when all required fields are filled', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockOnSaved).toHaveBeenCalledWith({ name: 'Valid Name' })
+ })
+
+ it('should not validate non-required empty fields', () => {
+ const schemasOptional = [
+ { name: 'optional', label: 'Optional', type: 'text-input', required: false, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockToastNotify).not.toHaveBeenCalled()
+ expect(mockOnSaved).toHaveBeenCalled()
+ })
+ })
+
+ describe('Boolean Field Processing', () => {
+ it('should convert string "true" to boolean true', () => {
+ const schemasWithBoolean = [
+ { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
+ })
+
+ it('should convert string "1" to boolean true', () => {
+ const schemasWithBoolean = [
+ { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
+ })
+
+ it('should convert string "True" to boolean true', () => {
+ const schemasWithBoolean = [
+ { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
+ })
+
+ it('should convert string "false" to boolean false', () => {
+ const schemasWithBoolean = [
+ { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
+ })
+
+ it('should convert number 1 to boolean true', () => {
+ const schemasWithBoolean = [
+ { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
+ })
+
+ it('should convert number 0 to boolean false', () => {
+ const schemasWithBoolean = [
+ { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
+ })
+
+ it('should preserve boolean true value', () => {
+ const schemasWithBoolean = [
+ { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
+ })
+
+ it('should preserve boolean false value', () => {
+ const schemasWithBoolean = [
+ { name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
+ })
+
+ it('should not process non-boolean fields', () => {
+ const schemasWithText = [
+ { name: 'text', label: 'Text', type: 'text-input', required: false, default: '' },
+ ] as unknown as FormSchema[]
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
+
+ expect(mockOnSaved).toHaveBeenCalledWith({ text: 'hello' })
+ })
+ })
+
+ describe('Memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ expect(EndpointModal).toBeDefined()
+ expect((EndpointModal as { $$typeof?: symbol }).$$typeof).toBeDefined()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/index.spec.tsx
new file mode 100644
index 0000000000..0cc9671e1b
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/index.spec.tsx
@@ -0,0 +1,1144 @@
+import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
+import PluginDetailPanel from './index'
+
+// Mock store
+const mockSetDetail = vi.fn()
+vi.mock('./store', () => ({
+ usePluginStore: () => ({
+ setDetail: mockSetDetail,
+ }),
+}))
+
+// Mock DetailHeader
+const mockDetailHeaderOnUpdate = vi.fn()
+vi.mock('./detail-header', () => ({
+ default: ({ detail, onUpdate, onHide }: {
+ detail: PluginDetail
+ onUpdate: (isDelete?: boolean) => void
+ onHide: () => void
+ }) => {
+ // Capture the onUpdate callback for testing
+ mockDetailHeaderOnUpdate.mockImplementation(onUpdate)
+ return (
+
+ {detail.name}
+
+
+
+
+ )
+ },
+}))
+
+// Mock ActionList
+vi.mock('./action-list', () => ({
+ default: ({ detail }: { detail: PluginDetail }) => (
+
+ {detail.plugin_id}
+
+ ),
+}))
+
+// Mock AgentStrategyList
+vi.mock('./agent-strategy-list', () => ({
+ default: ({ detail }: { detail: PluginDetail }) => (
+
+ {detail.plugin_id}
+
+ ),
+}))
+
+// Mock EndpointList
+vi.mock('./endpoint-list', () => ({
+ default: ({ detail }: { detail: PluginDetail }) => (
+
+ {detail.plugin_id}
+
+ ),
+}))
+
+// Mock ModelList
+vi.mock('./model-list', () => ({
+ default: ({ detail }: { detail: PluginDetail }) => (
+
+ {detail.plugin_id}
+
+ ),
+}))
+
+// Mock DatasourceActionList
+vi.mock('./datasource-action-list', () => ({
+ default: ({ detail }: { detail: PluginDetail }) => (
+
+ {detail.plugin_id}
+
+ ),
+}))
+
+// Mock SubscriptionList
+vi.mock('./subscription-list', () => ({
+ SubscriptionList: ({ pluginDetail }: { pluginDetail: PluginDetail }) => (
+
+ {pluginDetail.plugin_id}
+
+ ),
+}))
+
+// Mock TriggerEventsList
+vi.mock('./trigger/event-list', () => ({
+ TriggerEventsList: () => (
+ Events List
+ ),
+}))
+
+// Mock ReadmeEntrance
+vi.mock('../readme-panel/entrance', () => ({
+ ReadmeEntrance: ({ pluginDetail, className }: { pluginDetail: PluginDetail, className?: string }) => (
+
+ {pluginDetail.plugin_id}
+
+ ),
+}))
+
+// Mock classnames utility
+vi.mock('@/utils/classnames', () => ({
+ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
+}))
+
+// Factory function to create mock PluginDetail
+const createPluginDetail = (overrides: Partial = {}): PluginDetail => {
+ const baseDeclaration = {
+ plugin_unique_identifier: 'test-plugin-uid',
+ version: '1.0.0',
+ author: 'test-author',
+ icon: 'test-icon.png',
+ name: 'test-plugin',
+ category: PluginCategoryEnum.tool,
+ label: { en_US: 'Test Plugin' },
+ description: { en_US: 'Test plugin description' },
+ created_at: '2024-01-01T00:00:00Z',
+ resource: null,
+ plugins: null,
+ verified: true,
+ endpoint: undefined,
+ tool: {
+ identity: {
+ author: 'test-author',
+ name: 'test-tool',
+ description: { en_US: 'Test tool' },
+ icon: 'tool-icon.png',
+ label: { en_US: 'Test Tool' },
+ tags: [],
+ },
+ credentials_schema: [],
+ },
+ model: null,
+ tags: [],
+ agent_strategy: null,
+ meta: { version: '1.0.0' },
+ trigger: null,
+ datasource: null,
+ } as unknown as PluginDeclaration
+
+ return {
+ id: 'test-plugin-id',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-02T00:00:00Z',
+ name: 'Test Plugin',
+ plugin_id: 'test-plugin-id',
+ plugin_unique_identifier: 'test-plugin-uid',
+ declaration: baseDeclaration,
+ installation_id: 'install-1',
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-plugin-uid',
+ source: PluginSource.marketplace,
+ meta: undefined,
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+ ...overrides,
+ }
+}
+
+// Factory for trigger plugin
+const createTriggerPluginDetail = (overrides: Partial = {}): PluginDetail => {
+ const triggerDeclaration = {
+ ...createPluginDetail().declaration,
+ category: PluginCategoryEnum.trigger,
+ tool: undefined,
+ trigger: {
+ events: [],
+ identity: {
+ author: 'test-author',
+ name: 'test-trigger',
+ label: { en_US: 'Test Trigger' },
+ description: { en_US: 'Test trigger desc' },
+ icon: 'trigger-icon.png',
+ tags: [],
+ },
+ subscription_constructor: {
+ credentials_schema: [],
+ oauth_schema: { client_schema: [], credentials_schema: [] },
+ parameters: [],
+ },
+ subscription_schema: [],
+ },
+ } as unknown as PluginDeclaration
+
+ return createPluginDetail({
+ declaration: triggerDeclaration,
+ ...overrides,
+ })
+}
+
+// Factory for model plugin
+const createModelPluginDetail = (overrides: Partial = {}): PluginDetail => {
+ return createPluginDetail({
+ declaration: {
+ ...createPluginDetail().declaration,
+ category: PluginCategoryEnum.model,
+ tool: undefined,
+ model: { provider: 'test-provider' },
+ },
+ ...overrides,
+ })
+}
+
+// Factory for agent strategy plugin
+const createAgentStrategyPluginDetail = (overrides: Partial = {}): PluginDetail => {
+ const strategyDeclaration = {
+ ...createPluginDetail().declaration,
+ category: PluginCategoryEnum.agent,
+ tool: undefined,
+ agent_strategy: {
+ identity: {
+ author: 'test-author',
+ name: 'test-strategy',
+ label: { en_US: 'Test Strategy' },
+ description: { en_US: 'Test strategy desc' },
+ icon: 'strategy-icon.png',
+ tags: [],
+ },
+ },
+ } as unknown as PluginDeclaration
+
+ return createPluginDetail({
+ declaration: strategyDeclaration,
+ ...overrides,
+ })
+}
+
+// Factory for endpoint plugin
+const createEndpointPluginDetail = (overrides: Partial = {}): PluginDetail => {
+ return createPluginDetail({
+ declaration: {
+ ...createPluginDetail().declaration,
+ category: PluginCategoryEnum.extension,
+ tool: undefined,
+ endpoint: {
+ settings: [],
+ endpoints: [{ path: '/test', method: 'GET' }],
+ },
+ },
+ ...overrides,
+ })
+}
+
+// Factory for datasource plugin
+const createDatasourcePluginDetail = (overrides: Partial = {}): PluginDetail => {
+ const datasourceDeclaration = {
+ ...createPluginDetail().declaration,
+ category: PluginCategoryEnum.datasource,
+ tool: undefined,
+ datasource: {
+ identity: {
+ author: 'test-author',
+ name: 'test-datasource',
+ description: { en_US: 'Test datasource' },
+ icon: 'datasource-icon.png',
+ label: { en_US: 'Test Datasource' },
+ tags: [],
+ },
+ credentials_schema: [],
+ },
+ } as unknown as PluginDeclaration
+
+ return createPluginDetail({
+ declaration: datasourceDeclaration,
+ ...overrides,
+ })
+}
+
+describe('PluginDetailPanel', () => {
+ const mockOnUpdate = vi.fn()
+ const mockOnHide = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockSetDetail.mockClear()
+ mockOnUpdate.mockClear()
+ mockOnHide.mockClear()
+ mockDetailHeaderOnUpdate.mockClear()
+ })
+
+ describe('Rendering', () => {
+ it('should render nothing when detail is undefined', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(container).toBeEmptyDOMElement()
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('should render drawer when detail is provided', () => {
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByTestId('detail-header')).toBeInTheDocument()
+ })
+
+ it('should render detail header with plugin name', () => {
+ const detail = createPluginDetail({ name: 'My Custom Plugin' })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('header-title')).toHaveTextContent('My Custom Plugin')
+ })
+
+ it('should render readme entrance with plugin detail', () => {
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
+ expect(screen.getByTestId('readme-plugin-id')).toHaveTextContent('test-plugin-id')
+ })
+
+ it('should render drawer with correct styles', () => {
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ const drawer = screen.getByRole('dialog')
+ expect(drawer).toBeInTheDocument()
+ })
+ })
+
+ describe('Conditional Rendering by Plugin Category', () => {
+ it('should render ActionList for tool plugins', () => {
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('action-list')).toBeInTheDocument()
+ expect(screen.queryByTestId('model-list')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('endpoint-list')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('agent-strategy-list')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('subscription-list')).not.toBeInTheDocument()
+ })
+
+ it('should render ModelList for model plugins', () => {
+ const detail = createModelPluginDetail()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('model-list')).toBeInTheDocument()
+ expect(screen.queryByTestId('action-list')).not.toBeInTheDocument()
+ })
+
+ it('should render AgentStrategyList for agent strategy plugins', () => {
+ const detail = createAgentStrategyPluginDetail()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('agent-strategy-list')).toBeInTheDocument()
+ expect(screen.queryByTestId('action-list')).not.toBeInTheDocument()
+ })
+
+ it('should render EndpointList for endpoint plugins', () => {
+ const detail = createEndpointPluginDetail()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('endpoint-list')).toBeInTheDocument()
+ expect(screen.queryByTestId('action-list')).not.toBeInTheDocument()
+ })
+
+ it('should render DatasourceActionList for datasource plugins', () => {
+ const detail = createDatasourcePluginDetail()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('datasource-action-list')).toBeInTheDocument()
+ expect(screen.queryByTestId('action-list')).not.toBeInTheDocument()
+ })
+
+ it('should render SubscriptionList and TriggerEventsList for trigger plugins', () => {
+ const detail = createTriggerPluginDetail()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('subscription-list')).toBeInTheDocument()
+ expect(screen.getByTestId('trigger-events-list')).toBeInTheDocument()
+ expect(screen.queryByTestId('action-list')).not.toBeInTheDocument()
+ })
+
+ it('should render multiple lists when plugin has multiple declarations', () => {
+ const detail = createPluginDetail({
+ declaration: {
+ ...createPluginDetail().declaration,
+ tool: createPluginDetail().declaration.tool,
+ endpoint: {
+ settings: [],
+ endpoints: [{ path: '/api', method: 'POST' }],
+ },
+ },
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('action-list')).toBeInTheDocument()
+ expect(screen.getByTestId('endpoint-list')).toBeInTheDocument()
+ })
+ })
+
+ describe('Side Effects and Cleanup', () => {
+ it('should call setDetail with correct data when detail is provided', () => {
+ const detail = createPluginDetail({
+ plugin_id: 'my-plugin-id',
+ plugin_unique_identifier: 'my-plugin-uid',
+ name: 'My Plugin',
+ id: 'detail-id',
+ })
+
+ render(
+ ,
+ )
+
+ expect(mockSetDetail).toHaveBeenCalledTimes(1)
+ expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({
+ plugin_id: 'my-plugin-id',
+ plugin_unique_identifier: 'my-plugin-uid',
+ name: 'My Plugin',
+ id: 'detail-id',
+ provider: 'my-plugin-id/test-plugin',
+ }))
+ })
+
+ it('should call setDetail with undefined when detail becomes undefined', () => {
+ const detail = createPluginDetail()
+ const { rerender } = render(
+ ,
+ )
+
+ expect(mockSetDetail).toHaveBeenCalledTimes(1)
+
+ rerender(
+ ,
+ )
+
+ expect(mockSetDetail).toHaveBeenCalledTimes(2)
+ expect(mockSetDetail).toHaveBeenLastCalledWith(undefined)
+ })
+
+ it('should update store when detail changes', () => {
+ const detail1 = createPluginDetail({ plugin_id: 'plugin-1' })
+ const detail2 = createPluginDetail({ plugin_id: 'plugin-2' })
+
+ const { rerender } = render(
+ ,
+ )
+
+ expect(mockSetDetail).toHaveBeenCalledTimes(1)
+ expect(mockSetDetail).toHaveBeenLastCalledWith(expect.objectContaining({
+ plugin_id: 'plugin-1',
+ }))
+
+ rerender(
+ ,
+ )
+
+ expect(mockSetDetail).toHaveBeenCalledTimes(2)
+ expect(mockSetDetail).toHaveBeenLastCalledWith(expect.objectContaining({
+ plugin_id: 'plugin-2',
+ }))
+ })
+
+ it('should include declaration in setDetail call', () => {
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({
+ declaration: expect.any(Object),
+ }))
+ })
+ })
+
+ describe('Callback Stability and Memoization', () => {
+ it('should maintain stable callback reference via useCallback', () => {
+ const detail = createPluginDetail()
+ const onUpdate = vi.fn()
+ const onHide = vi.fn()
+
+ // Test that the callback is created with useCallback by verifying
+ // it depends on onHide and onUpdate (tested in other tests)
+ // This test verifies the basic rendering doesn't change the functionality
+ const { rerender } = render(
+ ,
+ )
+
+ // Initial click should work
+ fireEvent.click(screen.getByTestId('header-update-btn'))
+ expect(onUpdate).toHaveBeenCalledTimes(1)
+
+ // Re-render with same props
+ rerender(
+ ,
+ )
+
+ // Callback should still work after re-render
+ fireEvent.click(screen.getByTestId('header-update-btn'))
+ expect(onUpdate).toHaveBeenCalledTimes(2)
+ })
+
+ it('should update handleUpdate when onUpdate prop changes', () => {
+ const detail = createPluginDetail()
+ const onUpdate1 = vi.fn()
+ const onUpdate2 = vi.fn()
+ const onHide = vi.fn()
+
+ const { rerender } = render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('header-update-btn'))
+ expect(onUpdate1).toHaveBeenCalledTimes(1)
+
+ rerender(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('header-update-btn'))
+ expect(onUpdate2).toHaveBeenCalledTimes(1)
+ })
+
+ it('should update handleUpdate when onHide prop changes', () => {
+ const detail = createPluginDetail()
+ const onUpdate = vi.fn()
+ const onHide1 = vi.fn()
+ const onHide2 = vi.fn()
+
+ const { rerender } = render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('header-delete-btn'))
+ expect(onHide1).toHaveBeenCalledTimes(1)
+
+ rerender(
+ ,
+ )
+
+ onUpdate.mockClear()
+ fireEvent.click(screen.getByTestId('header-delete-btn'))
+ expect(onHide2).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('User Interactions and Event Handlers', () => {
+ it('should call onUpdate when update button is clicked', () => {
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('header-update-btn'))
+
+ expect(mockOnUpdate).toHaveBeenCalledTimes(1)
+ expect(mockOnHide).not.toHaveBeenCalled()
+ })
+
+ it('should call onHide and onUpdate when delete is triggered', () => {
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('header-delete-btn'))
+
+ expect(mockOnHide).toHaveBeenCalledTimes(1)
+ expect(mockOnUpdate).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onHide before onUpdate when isDelete is true', () => {
+ const callOrder: string[] = []
+ const onUpdate = vi.fn(() => callOrder.push('update'))
+ const onHide = vi.fn(() => callOrder.push('hide'))
+
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('header-delete-btn'))
+
+ expect(callOrder).toEqual(['hide', 'update'])
+ })
+
+ it('should call only onUpdate when isDelete is false', () => {
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('header-update-btn'))
+
+ expect(mockOnUpdate).toHaveBeenCalledTimes(1)
+ expect(mockOnHide).not.toHaveBeenCalled()
+ })
+
+ it('should call onHide when hide button is clicked', () => {
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('header-hide-btn'))
+
+ expect(mockOnHide).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onHide when drawer close is triggered', () => {
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ // Click the hide button in the header to close the drawer
+ fireEvent.click(screen.getByTestId('header-hide-btn'))
+
+ expect(mockOnHide).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Edge Cases and Error Handling', () => {
+ it('should handle plugin with empty declaration name gracefully', () => {
+ const detail = createPluginDetail({
+ declaration: {
+ ...createPluginDetail().declaration,
+ name: '',
+ },
+ })
+
+ render(
+ ,
+ )
+
+ expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({
+ provider: expect.stringContaining('/'),
+ }))
+ })
+
+ it('should handle plugin with empty plugin_unique_identifier', () => {
+ const detail = createPluginDetail({
+ plugin_unique_identifier: '',
+ })
+
+ render(
+ ,
+ )
+
+ expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({
+ plugin_unique_identifier: '',
+ }))
+ })
+
+ it('should handle plugin with undefined plugin_unique_identifier', () => {
+ const detail = createPluginDetail({
+ plugin_unique_identifier: undefined as unknown as string,
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('should handle plugin without tool, model, endpoint, agent_strategy, or datasource', () => {
+ const emptyDeclaration = {
+ ...createPluginDetail().declaration,
+ tool: undefined,
+ model: undefined,
+ endpoint: undefined,
+ agent_strategy: undefined,
+ datasource: undefined,
+ category: PluginCategoryEnum.extension,
+ } as unknown as PluginDeclaration
+
+ const detail = createPluginDetail({
+ declaration: emptyDeclaration,
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.queryByTestId('action-list')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('model-list')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('endpoint-list')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('agent-strategy-list')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('datasource-action-list')).not.toBeInTheDocument()
+ })
+
+ it('should handle rapid prop changes without errors', () => {
+ const detail1 = createPluginDetail({ plugin_id: 'plugin-1' })
+ const detail2 = createPluginDetail({ plugin_id: 'plugin-2' })
+ const detail3 = createPluginDetail({ plugin_id: 'plugin-3' })
+
+ const { rerender } = render(
+ ,
+ )
+
+ act(() => {
+ rerender(
+ ,
+ )
+ })
+
+ act(() => {
+ rerender(
+ ,
+ )
+ })
+
+ expect(mockSetDetail).toHaveBeenCalledTimes(3)
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('should handle toggle between defined and undefined detail', () => {
+ const detail = createPluginDetail()
+
+ const { rerender, container } = render(
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+
+ expect(container).toBeEmptyDOMElement()
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+ })
+
+ describe('Props Variations', () => {
+ it('should pass correct props to DetailHeader', () => {
+ const detail = createPluginDetail({ name: 'Custom Plugin Name' })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('header-title')).toHaveTextContent('Custom Plugin Name')
+ })
+
+ it('should handle different plugin sources', () => {
+ const sources: PluginSource[] = [
+ PluginSource.marketplace,
+ PluginSource.github,
+ PluginSource.local,
+ PluginSource.debugging,
+ ]
+
+ sources.forEach((source) => {
+ const detail = createPluginDetail({ source })
+ const { unmount } = render(
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ unmount()
+ })
+ })
+
+ it('should handle different plugin statuses', () => {
+ const statuses: Array<'active' | 'deleted'> = ['active', 'deleted']
+
+ statuses.forEach((status) => {
+ const detail = createPluginDetail({ status })
+ const { unmount } = render(
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ unmount()
+ })
+ })
+
+ it('should handle plugin with deprecated_reason', () => {
+ const detail = createPluginDetail({
+ deprecated_reason: 'This plugin is deprecated',
+ alternative_plugin_id: 'alternative-plugin',
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('should handle plugin with meta data for github source', () => {
+ const detail = createPluginDetail({
+ source: PluginSource.github,
+ meta: {
+ repo: 'owner/repo-name',
+ version: 'v1.2.3',
+ package: 'package.difypkg',
+ },
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('should handle plugin with different versions', () => {
+ const detail = createPluginDetail({
+ version: '1.0.0',
+ latest_version: '2.0.0',
+ latest_unique_identifier: 'new-uid',
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('should pass pluginDetail to SubscriptionList for trigger plugins', () => {
+ const detail = createTriggerPluginDetail({ plugin_id: 'trigger-plugin-123' })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('subscription-list-plugin-id')).toHaveTextContent('trigger-plugin-123')
+ })
+
+ it('should pass detail to ActionList for tool plugins', () => {
+ const detail = createPluginDetail({ plugin_id: 'tool-plugin-456' })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('action-list-plugin-id')).toHaveTextContent('tool-plugin-456')
+ })
+ })
+
+ describe('Store Integration', () => {
+ it('should construct provider correctly from plugin_id and declaration.name', () => {
+ const detail = createPluginDetail({
+ plugin_id: 'my-org/my-plugin',
+ declaration: {
+ ...createPluginDetail().declaration,
+ name: 'my-tool-name',
+ },
+ })
+
+ render(
+ ,
+ )
+
+ expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({
+ provider: 'my-org/my-plugin/my-tool-name',
+ }))
+ })
+
+ it('should include all required fields in setDetail payload', () => {
+ const detail = createPluginDetail()
+
+ render(
+ ,
+ )
+
+ expect(mockSetDetail).toHaveBeenCalledWith({
+ plugin_id: detail.plugin_id,
+ provider: expect.any(String),
+ plugin_unique_identifier: detail.plugin_unique_identifier,
+ declaration: detail.declaration,
+ name: detail.name,
+ id: detail.id,
+ })
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx
new file mode 100644
index 0000000000..2283ad0c43
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx
@@ -0,0 +1,103 @@
+import type { PluginDetail } from '@/app/components/plugins/types'
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ModelList from './model-list'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) => {
+ if (options?.num !== undefined)
+ return `${options.num} models`
+ return key
+ },
+ }),
+}))
+
+const mockModels = [
+ { model: 'gpt-4', provider: 'openai' },
+ { model: 'gpt-3.5', provider: 'openai' },
+]
+
+let mockModelListResponse: { data: typeof mockModels } | undefined
+
+vi.mock('@/service/use-models', () => ({
+ useModelProviderModelList: () => ({
+ data: mockModelListResponse,
+ }),
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
+ default: ({ modelName }: { modelName: string }) => (
+ {modelName}
+ ),
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({
+ default: ({ modelItem }: { modelItem: { model: string } }) => (
+ {modelItem.model}
+ ),
+}))
+
+const createPluginDetail = (): PluginDetail => ({
+ id: 'test-id',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-02',
+ name: 'Test Plugin',
+ plugin_id: 'test-plugin',
+ plugin_unique_identifier: 'test-uid',
+ declaration: {
+ model: { provider: 'openai' },
+ } as PluginDetail['declaration'],
+ installation_id: 'install-1',
+ tenant_id: 'tenant-1',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-uid',
+ source: 'marketplace' as PluginDetail['source'],
+ meta: undefined,
+ status: 'active',
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+})
+
+describe('ModelList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockModelListResponse = { data: mockModels }
+ })
+
+ describe('Rendering', () => {
+ it('should render model list when data is available', () => {
+ render()
+
+ expect(screen.getByText('2 models')).toBeInTheDocument()
+ })
+
+ it('should render model icons and names', () => {
+ render()
+
+ expect(screen.getAllByTestId('model-icon')).toHaveLength(2)
+ expect(screen.getAllByTestId('model-name')).toHaveLength(2)
+ // Both icon and name show the model name, so use getAllByText
+ expect(screen.getAllByText('gpt-4')).toHaveLength(2)
+ expect(screen.getAllByText('gpt-3.5')).toHaveLength(2)
+ })
+
+ it('should return null when no data', () => {
+ mockModelListResponse = undefined
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should handle empty model list', () => {
+ mockModelListResponse = { data: [] }
+ render()
+
+ expect(screen.getByText('0 models')).toBeInTheDocument()
+ expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx
new file mode 100644
index 0000000000..5501526b12
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx
@@ -0,0 +1,215 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PluginSource } from '../types'
+import OperationDropdown from './operation-dropdown'
+
+// Mock dependencies
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => T): T =>
+ selector({ systemFeatures: { enable_marketplace: true } }),
+}))
+
+vi.mock('@/utils/classnames', () => ({
+ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
+}))
+
+vi.mock('@/app/components/base/action-button', () => ({
+ default: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => (
+
+ ),
+}))
+
+vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
+ PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
+ {children}
+ ),
+ PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
+ {children}
+ ),
+ PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
+ {children}
+ ),
+}))
+
+describe('OperationDropdown', () => {
+ const mockOnInfo = vi.fn()
+ const mockOnCheckVersion = vi.fn()
+ const mockOnRemove = vi.fn()
+ const defaultProps = {
+ source: PluginSource.github,
+ detailUrl: 'https://github.com/test/repo',
+ onInfo: mockOnInfo,
+ onCheckVersion: mockOnCheckVersion,
+ onRemove: mockOnRemove,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render trigger button', () => {
+ render()
+
+ expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
+ expect(screen.getByTestId('action-button')).toBeInTheDocument()
+ })
+
+ it('should render dropdown content', () => {
+ render()
+
+ expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+ })
+
+ it('should render info option for github source', () => {
+ render()
+
+ expect(screen.getByText('detailPanel.operation.info')).toBeInTheDocument()
+ })
+
+ it('should render check update option for github source', () => {
+ render()
+
+ expect(screen.getByText('detailPanel.operation.checkUpdate')).toBeInTheDocument()
+ })
+
+ it('should render view detail option for github source with marketplace enabled', () => {
+ render()
+
+ expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument()
+ })
+
+ it('should render view detail option for marketplace source', () => {
+ render()
+
+ expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument()
+ })
+
+ it('should always render remove option', () => {
+ render()
+
+ expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument()
+ })
+
+ it('should not render info option for marketplace source', () => {
+ render()
+
+ expect(screen.queryByText('detailPanel.operation.info')).not.toBeInTheDocument()
+ })
+
+ it('should not render check update option for marketplace source', () => {
+ render()
+
+ expect(screen.queryByText('detailPanel.operation.checkUpdate')).not.toBeInTheDocument()
+ })
+
+ it('should not render view detail for local source', () => {
+ render()
+
+ expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument()
+ })
+
+ it('should not render view detail for debugging source', () => {
+ render()
+
+ expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should toggle dropdown when trigger is clicked', () => {
+ render()
+
+ const trigger = screen.getByTestId('portal-trigger')
+ fireEvent.click(trigger)
+
+ // The portal-elem should reflect the open state
+ expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ })
+
+ it('should call onInfo when info option is clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByText('detailPanel.operation.info'))
+
+ expect(mockOnInfo).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onCheckVersion when check update option is clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByText('detailPanel.operation.checkUpdate'))
+
+ expect(mockOnCheckVersion).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onRemove when remove option is clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByText('detailPanel.operation.remove'))
+
+ expect(mockOnRemove).toHaveBeenCalledTimes(1)
+ })
+
+ it('should have correct href for view detail link', () => {
+ render()
+
+ const link = screen.getByText('detailPanel.operation.viewDetail').closest('a')
+ expect(link).toHaveAttribute('href', 'https://github.com/test/repo')
+ expect(link).toHaveAttribute('target', '_blank')
+ })
+ })
+
+ describe('Props Variations', () => {
+ it('should handle all plugin sources', () => {
+ const sources = [
+ PluginSource.github,
+ PluginSource.marketplace,
+ PluginSource.local,
+ PluginSource.debugging,
+ ]
+
+ sources.forEach((source) => {
+ const { unmount } = render(
+ ,
+ )
+ expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
+ expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument()
+ unmount()
+ })
+ })
+
+ it('should handle different detail URLs', () => {
+ const urls = [
+ 'https://github.com/owner/repo',
+ 'https://marketplace.example.com/plugin/123',
+ ]
+
+ urls.forEach((url) => {
+ const { unmount } = render(
+ ,
+ )
+ const link = screen.getByText('detailPanel.operation.viewDetail').closest('a')
+ expect(link).toHaveAttribute('href', url)
+ unmount()
+ })
+ })
+ })
+
+ describe('Memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ // Verify the component is exported as a memo component
+ expect(OperationDropdown).toBeDefined()
+ // React.memo wraps the component, so it should have $$typeof
+ expect((OperationDropdown as { $$typeof?: symbol }).$$typeof).toBeDefined()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/store.spec.ts b/web/app/components/plugins/plugin-detail-panel/store.spec.ts
new file mode 100644
index 0000000000..4116bb9790
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/store.spec.ts
@@ -0,0 +1,461 @@
+import type { SimpleDetail } from './store'
+import { act, renderHook } from '@testing-library/react'
+import { beforeEach, describe, expect, it } from 'vitest'
+import { usePluginStore } from './store'
+
+// Factory function to create mock SimpleDetail
+const createSimpleDetail = (overrides: Partial = {}): SimpleDetail => ({
+ plugin_id: 'test-plugin-id',
+ name: 'Test Plugin',
+ plugin_unique_identifier: 'test-plugin-uid',
+ id: 'test-id',
+ provider: 'test-provider',
+ declaration: {
+ category: 'tool' as SimpleDetail['declaration']['category'],
+ name: 'test-declaration',
+ },
+ ...overrides,
+})
+
+describe('usePluginStore', () => {
+ beforeEach(() => {
+ // Reset store state before each test
+ const { result } = renderHook(() => usePluginStore())
+ act(() => {
+ result.current.setDetail(undefined)
+ })
+ })
+
+ describe('Initial State', () => {
+ it('should have undefined detail initially', () => {
+ const { result } = renderHook(() => usePluginStore())
+
+ expect(result.current.detail).toBeUndefined()
+ })
+
+ it('should provide setDetail function', () => {
+ const { result } = renderHook(() => usePluginStore())
+
+ expect(typeof result.current.setDetail).toBe('function')
+ })
+ })
+
+ describe('setDetail', () => {
+ it('should set detail with valid SimpleDetail', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const detail = createSimpleDetail()
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ expect(result.current.detail).toEqual(detail)
+ })
+
+ it('should set detail to undefined', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const detail = createSimpleDetail()
+
+ // First set a value
+ act(() => {
+ result.current.setDetail(detail)
+ })
+ expect(result.current.detail).toEqual(detail)
+
+ // Then clear it
+ act(() => {
+ result.current.setDetail(undefined)
+ })
+ expect(result.current.detail).toBeUndefined()
+ })
+
+ it('should update detail when called multiple times', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const detail1 = createSimpleDetail({ plugin_id: 'plugin-1' })
+ const detail2 = createSimpleDetail({ plugin_id: 'plugin-2' })
+
+ act(() => {
+ result.current.setDetail(detail1)
+ })
+ expect(result.current.detail?.plugin_id).toBe('plugin-1')
+
+ act(() => {
+ result.current.setDetail(detail2)
+ })
+ expect(result.current.detail?.plugin_id).toBe('plugin-2')
+ })
+
+ it('should handle detail with trigger declaration', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const detail = createSimpleDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [],
+ subscription_constructor: null,
+ },
+ },
+ })
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ expect(result.current.detail?.declaration.trigger).toEqual({
+ subscription_schema: [],
+ subscription_constructor: null,
+ })
+ })
+
+ it('should handle detail with partial declaration', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const detail = createSimpleDetail({
+ declaration: {
+ name: 'partial-plugin',
+ },
+ })
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ expect(result.current.detail?.declaration.name).toBe('partial-plugin')
+ })
+ })
+
+ describe('Store Sharing', () => {
+ it('should share state across multiple hook instances', () => {
+ const { result: result1 } = renderHook(() => usePluginStore())
+ const { result: result2 } = renderHook(() => usePluginStore())
+ const detail = createSimpleDetail()
+
+ act(() => {
+ result1.current.setDetail(detail)
+ })
+
+ // Both hooks should see the same state
+ expect(result1.current.detail).toEqual(detail)
+ expect(result2.current.detail).toEqual(detail)
+ })
+
+ it('should update all hook instances when state changes', () => {
+ const { result: result1 } = renderHook(() => usePluginStore())
+ const { result: result2 } = renderHook(() => usePluginStore())
+ const detail1 = createSimpleDetail({ name: 'Plugin One' })
+ const detail2 = createSimpleDetail({ name: 'Plugin Two' })
+
+ act(() => {
+ result1.current.setDetail(detail1)
+ })
+
+ expect(result1.current.detail?.name).toBe('Plugin One')
+ expect(result2.current.detail?.name).toBe('Plugin One')
+
+ act(() => {
+ result2.current.setDetail(detail2)
+ })
+
+ expect(result1.current.detail?.name).toBe('Plugin Two')
+ expect(result2.current.detail?.name).toBe('Plugin Two')
+ })
+ })
+
+ describe('Selector Pattern', () => {
+ // Extract selectors to reduce nesting depth
+ const selectDetail = (state: ReturnType) => state.detail
+ const selectSetDetail = (state: ReturnType) => state.setDetail
+
+ it('should support selector to get specific field', () => {
+ const { result: setterResult } = renderHook(() => usePluginStore())
+ const detail = createSimpleDetail({ plugin_id: 'selected-plugin' })
+
+ act(() => {
+ setterResult.current.setDetail(detail)
+ })
+
+ // Use selector to get only detail
+ const { result: selectorResult } = renderHook(() => usePluginStore(selectDetail))
+
+ expect(selectorResult.current?.plugin_id).toBe('selected-plugin')
+ })
+
+ it('should support selector to get setDetail function', () => {
+ const { result } = renderHook(() => usePluginStore(selectSetDetail))
+
+ expect(typeof result.current).toBe('function')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty string values in detail', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const detail = createSimpleDetail({
+ plugin_id: '',
+ name: '',
+ plugin_unique_identifier: '',
+ provider: '',
+ })
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ expect(result.current.detail?.plugin_id).toBe('')
+ expect(result.current.detail?.name).toBe('')
+ })
+
+ it('should handle detail with empty declaration', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const detail = createSimpleDetail({
+ declaration: {},
+ })
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ expect(result.current.detail?.declaration).toEqual({})
+ })
+
+ it('should handle rapid state updates', () => {
+ const { result } = renderHook(() => usePluginStore())
+
+ act(() => {
+ for (let i = 0; i < 10; i++)
+ result.current.setDetail(createSimpleDetail({ plugin_id: `plugin-${i}` }))
+ })
+
+ expect(result.current.detail?.plugin_id).toBe('plugin-9')
+ })
+
+ it('should handle setDetail called without arguments', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const detail = createSimpleDetail()
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+ expect(result.current.detail).toBeDefined()
+
+ act(() => {
+ result.current.setDetail()
+ })
+ expect(result.current.detail).toBeUndefined()
+ })
+ })
+
+ describe('Type Safety', () => {
+ it('should preserve all SimpleDetail fields correctly', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const detail: SimpleDetail = {
+ plugin_id: 'type-test-id',
+ name: 'Type Test Plugin',
+ plugin_unique_identifier: 'type-test-uid',
+ id: 'type-id',
+ provider: 'type-provider',
+ declaration: {
+ category: 'model' as SimpleDetail['declaration']['category'],
+ name: 'type-declaration',
+ version: '2.0.0',
+ author: 'test-author',
+ },
+ }
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ expect(result.current.detail).toStrictEqual(detail)
+ expect(result.current.detail?.plugin_id).toBe('type-test-id')
+ expect(result.current.detail?.name).toBe('Type Test Plugin')
+ expect(result.current.detail?.plugin_unique_identifier).toBe('type-test-uid')
+ expect(result.current.detail?.id).toBe('type-id')
+ expect(result.current.detail?.provider).toBe('type-provider')
+ })
+
+ it('should handle declaration with subscription_constructor', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const mockConstructor = {
+ credentials_schema: [],
+ oauth_schema: {
+ client_schema: [],
+ credentials_schema: [],
+ },
+ parameters: [],
+ }
+
+ const detail = createSimpleDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [],
+ subscription_constructor: mockConstructor as unknown as NonNullable['subscription_constructor'],
+ },
+ },
+ })
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ expect(result.current.detail?.declaration.trigger?.subscription_constructor).toBeDefined()
+ })
+
+ it('should handle declaration with subscription_schema', () => {
+ const { result } = renderHook(() => usePluginStore())
+
+ const detail = createSimpleDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [],
+ subscription_constructor: null,
+ },
+ },
+ })
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ expect(result.current.detail?.declaration.trigger?.subscription_schema).toEqual([])
+ })
+ })
+
+ describe('State Persistence', () => {
+ it('should maintain state after multiple renders', () => {
+ const detail = createSimpleDetail({ name: 'Persistent Plugin' })
+
+ const { result, rerender } = renderHook(() => usePluginStore())
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ // Rerender multiple times
+ rerender()
+ rerender()
+ rerender()
+
+ expect(result.current.detail?.name).toBe('Persistent Plugin')
+ })
+
+ it('should maintain reference equality for unchanged state', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const detail = createSimpleDetail()
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ const firstDetailRef = result.current.detail
+
+ // Get state again without changing
+ const { result: result2 } = renderHook(() => usePluginStore())
+
+ expect(result2.current.detail).toBe(firstDetailRef)
+ })
+ })
+
+ describe('Concurrent Updates', () => {
+ it('should handle updates from multiple sources correctly', () => {
+ const { result: hook1 } = renderHook(() => usePluginStore())
+ const { result: hook2 } = renderHook(() => usePluginStore())
+ const { result: hook3 } = renderHook(() => usePluginStore())
+
+ act(() => {
+ hook1.current.setDetail(createSimpleDetail({ name: 'From Hook 1' }))
+ })
+
+ act(() => {
+ hook2.current.setDetail(createSimpleDetail({ name: 'From Hook 2' }))
+ })
+
+ act(() => {
+ hook3.current.setDetail(createSimpleDetail({ name: 'From Hook 3' }))
+ })
+
+ // All hooks should reflect the last update
+ expect(hook1.current.detail?.name).toBe('From Hook 3')
+ expect(hook2.current.detail?.name).toBe('From Hook 3')
+ expect(hook3.current.detail?.name).toBe('From Hook 3')
+ })
+
+ it('should handle interleaved read and write operations', () => {
+ const { result } = renderHook(() => usePluginStore())
+
+ act(() => {
+ result.current.setDetail(createSimpleDetail({ plugin_id: 'step-1' }))
+ })
+ expect(result.current.detail?.plugin_id).toBe('step-1')
+
+ act(() => {
+ result.current.setDetail(createSimpleDetail({ plugin_id: 'step-2' }))
+ })
+ expect(result.current.detail?.plugin_id).toBe('step-2')
+
+ act(() => {
+ result.current.setDetail(undefined)
+ })
+ expect(result.current.detail).toBeUndefined()
+
+ act(() => {
+ result.current.setDetail(createSimpleDetail({ plugin_id: 'step-3' }))
+ })
+ expect(result.current.detail?.plugin_id).toBe('step-3')
+ })
+ })
+
+ describe('Declaration Variations', () => {
+ it('should handle declaration with all optional fields', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const detail = createSimpleDetail({
+ declaration: {
+ category: 'extension' as SimpleDetail['declaration']['category'],
+ name: 'full-declaration',
+ version: '1.0.0',
+ author: 'full-author',
+ icon: 'icon.png',
+ verified: true,
+ tags: ['tag1', 'tag2'],
+ },
+ })
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ const decl = result.current.detail?.declaration
+ expect(decl?.category).toBe('extension')
+ expect(decl?.name).toBe('full-declaration')
+ expect(decl?.version).toBe('1.0.0')
+ expect(decl?.author).toBe('full-author')
+ expect(decl?.icon).toBe('icon.png')
+ expect(decl?.verified).toBe(true)
+ expect(decl?.tags).toEqual(['tag1', 'tag2'])
+ })
+
+ it('should handle declaration with nested tool object', () => {
+ const { result } = renderHook(() => usePluginStore())
+ const mockTool = {
+ identity: {
+ author: 'tool-author',
+ name: 'tool-name',
+ icon: 'tool-icon.png',
+ tags: ['api', 'utility'],
+ },
+ credentials_schema: [],
+ }
+
+ const detail = createSimpleDetail({
+ declaration: {
+ tool: mockTool as unknown as SimpleDetail['declaration']['tool'],
+ },
+ })
+
+ act(() => {
+ result.current.setDetail(detail)
+ })
+
+ expect(result.current.detail?.declaration.tool?.identity.name).toBe('tool-name')
+ expect(result.current.detail?.declaration.tool?.identity.tags).toEqual(['api', 'utility'])
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx b/web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx
new file mode 100644
index 0000000000..32ae6ff735
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx
@@ -0,0 +1,203 @@
+import type { StrategyDetail as StrategyDetailType } from '@/app/components/plugins/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import StrategyDetail from './strategy-detail'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/hooks/use-i18n', () => ({
+ useRenderI18nObject: () => (obj: Record) => obj?.en_US || '',
+}))
+
+vi.mock('@/utils/classnames', () => ({
+ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
+}))
+
+vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/plugins/card/base/description', () => ({
+ default: ({ text }: { text: string }) => {text}
,
+}))
+
+type ProviderType = Parameters[0]['provider']
+
+const mockProvider = {
+ author: 'test-author',
+ name: 'test-provider',
+ description: { en_US: 'Provider desc' },
+ tenant_id: 'tenant-1',
+ icon: 'icon.png',
+ label: { en_US: 'Test Provider' },
+ tags: [],
+} as unknown as ProviderType
+
+const mockDetail = {
+ identity: {
+ author: 'author-1',
+ name: 'strategy-1',
+ icon: 'icon.png',
+ label: { en_US: 'Strategy Label' },
+ provider: 'provider-1',
+ },
+ parameters: [
+ {
+ name: 'param1',
+ label: { en_US: 'Parameter 1' },
+ type: 'text-input',
+ required: true,
+ human_description: { en_US: 'A text parameter' },
+ },
+ ],
+ description: { en_US: 'Strategy description' },
+ output_schema: {
+ properties: {
+ result: { type: 'string', description: 'Result output' },
+ items: { type: 'array', items: { type: 'string' }, description: 'Array items' },
+ },
+ },
+ features: [],
+} as unknown as StrategyDetailType
+
+describe('StrategyDetail', () => {
+ const mockOnHide = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render drawer', () => {
+ render()
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('should render provider label', () => {
+ render()
+
+ expect(screen.getByText('Test Provider')).toBeInTheDocument()
+ })
+
+ it('should render strategy label', () => {
+ render()
+
+ expect(screen.getByText('Strategy Label')).toBeInTheDocument()
+ })
+
+ it('should render parameters section', () => {
+ render()
+
+ expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
+ expect(screen.getByText('Parameter 1')).toBeInTheDocument()
+ })
+
+ it('should render output schema section', () => {
+ render()
+
+ expect(screen.getByText('OUTPUT')).toBeInTheDocument()
+ expect(screen.getByText('result')).toBeInTheDocument()
+ expect(screen.getByText('String')).toBeInTheDocument()
+ })
+
+ it('should render BACK button', () => {
+ render()
+
+ expect(screen.getByText('BACK')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onHide when close button clicked', () => {
+ render()
+
+ // Find the close button (ActionButton with action-btn class)
+ const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
+ if (closeButton)
+ fireEvent.click(closeButton)
+
+ expect(mockOnHide).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onHide when BACK clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByText('BACK'))
+
+ expect(mockOnHide).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Parameter Types', () => {
+ it('should display correct type for number-input', () => {
+ const detailWithNumber = {
+ ...mockDetail,
+ parameters: [{ ...mockDetail.parameters[0], type: 'number-input' }],
+ }
+ render()
+
+ expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument()
+ })
+
+ it('should display correct type for checkbox', () => {
+ const detailWithCheckbox = {
+ ...mockDetail,
+ parameters: [{ ...mockDetail.parameters[0], type: 'checkbox' }],
+ }
+ render()
+
+ expect(screen.getByText('boolean')).toBeInTheDocument()
+ })
+
+ it('should display correct type for file', () => {
+ const detailWithFile = {
+ ...mockDetail,
+ parameters: [{ ...mockDetail.parameters[0], type: 'file' }],
+ }
+ render()
+
+ expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument()
+ })
+
+ it('should display correct type for array[tools]', () => {
+ const detailWithArrayTools = {
+ ...mockDetail,
+ parameters: [{ ...mockDetail.parameters[0], type: 'array[tools]' }],
+ }
+ render()
+
+ expect(screen.getByText('multiple-tool-select')).toBeInTheDocument()
+ })
+
+ it('should display original type for unknown types', () => {
+ const detailWithUnknown = {
+ ...mockDetail,
+ parameters: [{ ...mockDetail.parameters[0], type: 'custom-type' }],
+ }
+ render()
+
+ expect(screen.getByText('custom-type')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty parameters', () => {
+ const detailEmpty = { ...mockDetail, parameters: [] }
+ render()
+
+ expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
+ })
+
+ it('should handle no output schema', () => {
+ const detailNoOutput = { ...mockDetail, output_schema: undefined as unknown as Record }
+ render()
+
+ expect(screen.queryByText('OUTPUT')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx b/web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx
new file mode 100644
index 0000000000..fde2f82965
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx
@@ -0,0 +1,102 @@
+import type { StrategyDetail } from '@/app/components/plugins/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import StrategyItem from './strategy-item'
+
+vi.mock('@/hooks/use-i18n', () => ({
+ useRenderI18nObject: () => (obj: Record) => obj?.en_US || '',
+}))
+
+vi.mock('@/utils/classnames', () => ({
+ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
+}))
+
+vi.mock('./strategy-detail', () => ({
+ default: ({ onHide }: { onHide: () => void }) => (
+
+
+
+ ),
+}))
+
+const mockProvider = {
+ author: 'test-author',
+ name: 'test-provider',
+ description: { en_US: 'Provider desc' } as Record,
+ tenant_id: 'tenant-1',
+ icon: 'icon.png',
+ label: { en_US: 'Test Provider' } as Record,
+ tags: [] as string[],
+}
+
+const mockDetail = {
+ identity: {
+ author: 'author-1',
+ name: 'strategy-1',
+ icon: 'icon.png',
+ label: { en_US: 'Strategy Label' } as Record,
+ provider: 'provider-1',
+ },
+ parameters: [],
+ description: { en_US: 'Strategy description' } as Record,
+ output_schema: {},
+ features: [],
+} as StrategyDetail
+
+describe('StrategyItem', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render strategy label', () => {
+ render()
+
+ expect(screen.getByText('Strategy Label')).toBeInTheDocument()
+ })
+
+ it('should render strategy description', () => {
+ render()
+
+ expect(screen.getByText('Strategy description')).toBeInTheDocument()
+ })
+
+ it('should not show detail panel initially', () => {
+ render()
+
+ expect(screen.queryByTestId('strategy-detail-panel')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should show detail panel when clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByText('Strategy Label'))
+
+ expect(screen.getByTestId('strategy-detail-panel')).toBeInTheDocument()
+ })
+
+ it('should hide detail panel when hide is called', () => {
+ render()
+
+ fireEvent.click(screen.getByText('Strategy Label'))
+ expect(screen.getByTestId('strategy-detail-panel')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('hide-btn'))
+ expect(screen.queryByTestId('strategy-detail-panel')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should handle empty description', () => {
+ const detailWithEmptyDesc = {
+ ...mockDetail,
+ description: { en_US: '' } as Record,
+ } as StrategyDetail
+ render()
+
+ expect(screen.getByText('Strategy Label')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx
index c87fc1e4da..543d3deebc 100644
--- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx
@@ -1874,4 +1874,187 @@ describe('CommonCreateModal', () => {
expect(screen.getByTestId('modal')).toHaveAttribute('data-disabled', 'true')
})
})
+
+ describe('normalizeFormType Additional Branches', () => {
+ it('should handle "text" type by returning textInput', () => {
+ const detailWithText = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'text_type_field', type: 'text' },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithText)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-text_type_field')).toBeInTheDocument()
+ })
+
+ it('should handle "secret" type by returning secretInput', () => {
+ const detailWithSecret = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'secret_type_field', type: 'secret' },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithSecret)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-secret_type_field')).toBeInTheDocument()
+ })
+ })
+
+ describe('HandleManualPropertiesChange Provider Fallback', () => {
+ it('should not call updateBuilder when provider is empty', async () => {
+ const detailWithEmptyProvider = createMockPluginDetail({
+ provider: '',
+ declaration: {
+ trigger: {
+ subscription_schema: [
+ { name: 'webhook_url', type: 'text', required: true },
+ ],
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithEmptyProvider)
+
+ render()
+
+ const input = screen.getByTestId('form-field-webhook_url')
+ fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
+
+ // updateBuilder should not be called when provider is empty
+ expect(mockUpdateBuilder).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Configuration Step Without Endpoint', () => {
+ it('should handle builder without endpoint', async () => {
+ const builderWithoutEndpoint = createMockSubscriptionBuilder({
+ endpoint: '',
+ })
+
+ render()
+
+ // Component should render without errors
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+ })
+
+ describe('ApiKeyStep Flow Additional Coverage', () => {
+ it('should handle verify when no builder created yet', async () => {
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+
+ // Make createBuilder slow
+ mockCreateBuilder.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)))
+
+ render()
+
+ // Click verify before builder is created
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ // Should still attempt to verify
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+ })
+
+ describe('Auto Parameters Not For APIKEY in Configuration', () => {
+ it('should include parameters for APIKEY in configuration step', async () => {
+ const detailWithParams = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ parameters: [
+ { name: 'extra_param', type: 'string', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithParams)
+
+ // First verify credentials
+ mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ // Click verify
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockVerifyCredentials).toHaveBeenCalled()
+ })
+
+ // Now in configuration step, should see extra_param
+ expect(screen.getByTestId('form-field-extra_param')).toBeInTheDocument()
+ })
+ })
+
+ describe('needCheckValidatedValues Option', () => {
+ it('should pass needCheckValidatedValues: false for manual properties', async () => {
+ const detailWithManualSchema = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [
+ { name: 'webhook_url', type: 'text', required: true },
+ ],
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithManualSchema)
+
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalled()
+ })
+
+ const input = screen.getByTestId('form-field-webhook_url')
+ fireEvent.change(input, { target: { value: 'test' } })
+
+ await waitFor(() => {
+ expect(mockUpdateBuilder).toHaveBeenCalled()
+ })
+ })
+ })
})
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx
index 0a23062717..0ad6bc364e 100644
--- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx
@@ -1475,4 +1475,213 @@ describe('CreateSubscriptionButton', () => {
})
})
})
+
+ // ==================== OAuth Callback Edge Cases ====================
+ describe('OAuth Callback - Falsy Data', () => {
+ it('should not open modal when OAuth callback returns falsy data', async () => {
+ // Arrange
+ const { openOAuthPopup } = await import('@/hooks/use-oauth')
+ vi.mocked(openOAuthPopup).mockImplementation((url: string, callback: (data?: unknown) => void) => {
+ callback(undefined) // falsy callback data
+ return null
+ })
+
+ const mockBuilder: TriggerSubscriptionBuilder = {
+ id: 'oauth-builder',
+ name: 'OAuth Builder',
+ provider: 'test-provider',
+ credential_type: TriggerCredentialTypeEnum.Oauth2,
+ credentials: {},
+ endpoint: 'https://test.com',
+ parameters: {},
+ properties: {},
+ workflows_in_use: 0,
+ }
+
+ mockInitiateOAuth.mockImplementation((_provider: string, callbacks: { onSuccess: (response: { authorization_url: string, subscription_builder: TriggerSubscriptionBuilder }) => void }) => {
+ callbacks.onSuccess({
+ authorization_url: 'https://oauth.test.com/authorize',
+ subscription_builder: mockBuilder,
+ })
+ })
+
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL],
+ }),
+ oauthConfig: createOAuthConfig({ configured: true }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on OAuth option
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ // Assert - modal should NOT open because callback data was falsy
+ await waitFor(() => {
+ expect(screen.queryByTestId('common-create-modal')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ // ==================== TriggerProps ClassName Branches ====================
+ describe('TriggerProps ClassName Branches', () => {
+ it('should apply pointer-events-none when non-default method with multiple supported methods', () => {
+ // Arrange - Single APIKEY method (methodType = APIKEY, not DEFAULT_METHOD)
+ // But we need multiple methods to test this branch
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.APIKEY, SupportedCreationMethods.MANUAL],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // The methodType will be DEFAULT_METHOD since multiple methods
+ // This verifies the render doesn't crash with multiple methods
+ expect(screen.getByTestId('custom-select')).toHaveAttribute('data-value', 'default')
+ })
+ })
+
+ // ==================== Tooltip Disabled Branches ====================
+ describe('Tooltip Disabled Branches', () => {
+ it('should enable tooltip when single method and not at max count', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ subscriptions: [createSubscription()], // Not at max
+ })
+ const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
+
+ // Act
+ render()
+
+ // Assert - tooltip should be enabled (disabled prop = false for single method)
+ expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
+ })
+
+ it('should disable tooltip when multiple methods and not at max count', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
+ }),
+ subscriptions: [createSubscription()], // Not at max
+ })
+ const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
+
+ // Act
+ render()
+
+ // Assert - tooltip should be disabled (neither single method nor at max)
+ expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Tooltip PopupContent Branches ====================
+ describe('Tooltip PopupContent Branches', () => {
+ it('should show max count message when at max subscriptions', () => {
+ // Arrange
+ const maxSubscriptions = createMaxSubscriptions()
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ subscriptions: maxSubscriptions,
+ })
+ const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
+
+ // Act
+ render()
+
+ // Assert - component renders with max subscriptions
+ expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
+ })
+
+ it('should show method description when not at max', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ subscriptions: [], // Not at max
+ })
+ const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
+
+ // Act
+ render()
+
+ // Assert - component renders without max subscriptions
+ expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Provider Info Fallbacks ====================
+ describe('Provider Info Fallbacks', () => {
+ it('should handle undefined supported_creation_methods', () => {
+ // Arrange - providerInfo with undefined supported_creation_methods
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: {
+ ...createProviderInfo(),
+ supported_creation_methods: undefined as unknown as SupportedCreationMethods[],
+ },
+ })
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render()
+
+ // Assert - should render null when supported methods fallback to empty
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should handle providerInfo with null supported_creation_methods', () => {
+ // Arrange
+ mockProviderInfo = { data: { ...createProviderInfo(), supported_creation_methods: null as unknown as SupportedCreationMethods[] } }
+ mockOAuthConfig = { data: undefined, refetch: vi.fn() }
+ mockStoreDetail = createStoreDetail()
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render()
+
+ // Assert - should render null
+ expect(container).toBeEmptyDOMElement()
+ })
+ })
+
+ // ==================== Method Type Logic ====================
+ describe('Method Type Logic', () => {
+ it('should use single method as methodType when only one supported', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.APIKEY],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ const customSelect = screen.getByTestId('custom-select')
+ expect(customSelect).toHaveAttribute('data-value', SupportedCreationMethods.APIKEY)
+ })
+ })
})
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx
index f1cb7a65ae..a842c63cfd 100644
--- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx
@@ -1240,4 +1240,60 @@ describe('OAuthClientSettingsModal', () => {
vi.useRealTimers()
})
})
+
+ describe('OAuth Client Schema Params Fallback', () => {
+ it('should handle schema when params is truthy but schema name not in params', () => {
+ const configWithSchemaNotInParams = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: {
+ client_id: 'test-id',
+ client_secret: 'test-secret',
+ },
+ oauth_client_schema: [
+ { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
+ { name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown },
+ { name: 'extra_field', type: 'text-input' as unknown, required: false, label: { 'en-US': 'Extra' } as unknown },
+ ] as TriggerOAuthConfig['oauth_client_schema'],
+ })
+
+ render()
+
+ // extra_field should be rendered but without default value
+ const extraInput = screen.getByTestId('form-field-extra_field') as HTMLInputElement
+ expect(extraInput.defaultValue).toBe('')
+ })
+
+ it('should handle oauth_client_schema with undefined params', () => {
+ const configWithUndefinedParams = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: undefined as unknown as TriggerOAuthConfig['params'],
+ oauth_client_schema: [
+ { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
+ ] as TriggerOAuthConfig['oauth_client_schema'],
+ })
+
+ render()
+
+ // Form should not render because params is undefined (schema condition fails)
+ expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
+ })
+
+ it('should handle oauth_client_schema with null params', () => {
+ const configWithNullParams = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: null as unknown as TriggerOAuthConfig['params'],
+ oauth_client_schema: [
+ { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
+ ] as TriggerOAuthConfig['oauth_client_schema'],
+ })
+
+ render()
+
+ // Form should not render because params is null
+ expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
+ })
+ })
})
diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx
new file mode 100644
index 0000000000..5ae7b62f13
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx
@@ -0,0 +1,287 @@
+import type { TriggerEvent } from '@/app/components/plugins/types'
+import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { EventDetailDrawer } from './event-detail-drawer'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useLanguage: () => 'en_US',
+}))
+
+vi.mock('@/utils/classnames', () => ({
+ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
+}))
+
+vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/plugins/card/base/description', () => ({
+ default: ({ text }: { text: string }) => {text}
,
+}))
+
+vi.mock('@/app/components/plugins/card/base/org-info', () => ({
+ default: ({ orgName }: { orgName: string }) => {orgName}
,
+}))
+
+vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
+ triggerEventParametersToFormSchemas: (params: Array>) =>
+ params.map(p => ({
+ label: (p.label as Record) || { en_US: p.name as string },
+ type: (p.type as string) || 'text-input',
+ required: (p.required as boolean) || false,
+ description: p.description as Record | undefined,
+ })),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field', () => ({
+ default: ({ name }: { name: string }) => {name}
,
+}))
+
+const mockEventInfo = {
+ name: 'test-event',
+ identity: {
+ author: 'test-author',
+ name: 'test-event',
+ label: { en_US: 'Test Event' },
+ },
+ description: { en_US: 'Test event description' },
+ parameters: [
+ {
+ name: 'param1',
+ label: { en_US: 'Parameter 1' },
+ type: 'text-input',
+ auto_generate: null,
+ template: null,
+ scope: null,
+ required: true,
+ multiple: false,
+ default: null,
+ min: null,
+ max: null,
+ precision: null,
+ description: { en_US: 'A test parameter' },
+ },
+ ],
+ output_schema: {
+ properties: {
+ result: { type: 'string', description: 'Result' },
+ },
+ required: ['result'],
+ },
+} as unknown as TriggerEvent
+
+const mockProviderInfo = {
+ provider: 'test-provider',
+ author: 'test-author',
+ name: 'test-provider/test-name',
+ icon: 'icon.png',
+ description: { en_US: 'Provider desc' },
+ supported_creation_methods: [],
+} as unknown as TriggerProviderApiEntity
+
+describe('EventDetailDrawer', () => {
+ const mockOnClose = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render drawer', () => {
+ render()
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('should render event title', () => {
+ render()
+
+ expect(screen.getByText('Test Event')).toBeInTheDocument()
+ })
+
+ it('should render event description', () => {
+ render()
+
+ expect(screen.getByTestId('description')).toHaveTextContent('Test event description')
+ })
+
+ it('should render org info', () => {
+ render()
+
+ expect(screen.getByTestId('org-info')).toBeInTheDocument()
+ })
+
+ it('should render parameters section', () => {
+ render()
+
+ expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
+ expect(screen.getByText('Parameter 1')).toBeInTheDocument()
+ })
+
+ it('should render output section', () => {
+ render()
+
+ expect(screen.getByText('events.output')).toBeInTheDocument()
+ expect(screen.getByTestId('output-field')).toHaveTextContent('result')
+ })
+
+ it('should render back button', () => {
+ render()
+
+ expect(screen.getByText('detailPanel.operation.back')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClose when close button clicked', () => {
+ render()
+
+ // Find the close button (ActionButton with action-btn class)
+ const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
+ if (closeButton)
+ fireEvent.click(closeButton)
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onClose when back clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByText('detailPanel.operation.back'))
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle no parameters', () => {
+ const eventWithNoParams = { ...mockEventInfo, parameters: [] }
+ render()
+
+ expect(screen.getByText('events.item.noParameters')).toBeInTheDocument()
+ })
+
+ it('should handle no output schema', () => {
+ const eventWithNoOutput = { ...mockEventInfo, output_schema: {} }
+ render()
+
+ expect(screen.getByText('events.output')).toBeInTheDocument()
+ expect(screen.queryByTestId('output-field')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Parameter Types', () => {
+ it('should display correct type for number-input', () => {
+ const eventWithNumber = {
+ ...mockEventInfo,
+ parameters: [{ ...mockEventInfo.parameters[0], type: 'number-input' }],
+ }
+ render()
+
+ expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument()
+ })
+
+ it('should display correct type for checkbox', () => {
+ const eventWithCheckbox = {
+ ...mockEventInfo,
+ parameters: [{ ...mockEventInfo.parameters[0], type: 'checkbox' }],
+ }
+ render()
+
+ expect(screen.getByText('boolean')).toBeInTheDocument()
+ })
+
+ it('should display correct type for file', () => {
+ const eventWithFile = {
+ ...mockEventInfo,
+ parameters: [{ ...mockEventInfo.parameters[0], type: 'file' }],
+ }
+ render()
+
+ expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument()
+ })
+
+ it('should display original type for unknown types', () => {
+ const eventWithUnknown = {
+ ...mockEventInfo,
+ parameters: [{ ...mockEventInfo.parameters[0], type: 'custom-type' }],
+ }
+ render()
+
+ expect(screen.getByText('custom-type')).toBeInTheDocument()
+ })
+ })
+
+ describe('Output Schema Conversion', () => {
+ it('should handle array type in output schema', () => {
+ const eventWithArrayOutput = {
+ ...mockEventInfo,
+ output_schema: {
+ properties: {
+ items: { type: 'array', items: { type: 'string' }, description: 'Array items' },
+ },
+ required: [],
+ },
+ }
+ render()
+
+ expect(screen.getByText('events.output')).toBeInTheDocument()
+ })
+
+ it('should handle nested properties in output schema', () => {
+ const eventWithNestedOutput = {
+ ...mockEventInfo,
+ output_schema: {
+ properties: {
+ nested: {
+ type: 'object',
+ properties: { inner: { type: 'string' } },
+ required: ['inner'],
+ },
+ },
+ required: [],
+ },
+ }
+ render()
+
+ expect(screen.getByText('events.output')).toBeInTheDocument()
+ })
+
+ it('should handle enum in output schema', () => {
+ const eventWithEnumOutput = {
+ ...mockEventInfo,
+ output_schema: {
+ properties: {
+ status: { type: 'string', enum: ['active', 'inactive'], description: 'Status' },
+ },
+ required: [],
+ },
+ }
+ render()
+
+ expect(screen.getByText('events.output')).toBeInTheDocument()
+ })
+
+ it('should handle array type schema', () => {
+ const eventWithArrayType = {
+ ...mockEventInfo,
+ output_schema: {
+ properties: {
+ multi: { type: ['string', 'null'], description: 'Multi type' },
+ },
+ required: [],
+ },
+ }
+ render()
+
+ expect(screen.getByText('events.output')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx
new file mode 100644
index 0000000000..2687319fbc
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx
@@ -0,0 +1,146 @@
+import type { TriggerEvent } from '@/app/components/plugins/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { TriggerEventsList } from './event-list'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) => {
+ if (options?.num !== undefined)
+ return `${options.num} ${options.event || 'events'}`
+ return key
+ },
+ }),
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useLanguage: () => 'en_US',
+}))
+
+vi.mock('@/utils/classnames', () => ({
+ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
+}))
+
+const mockTriggerEvents = [
+ {
+ name: 'event-1',
+ identity: {
+ author: 'author-1',
+ name: 'event-1',
+ label: { en_US: 'Event One' },
+ },
+ description: { en_US: 'Event one description' },
+ parameters: [],
+ output_schema: {},
+ },
+] as unknown as TriggerEvent[]
+
+let mockDetail: { plugin_id: string, provider: string } | undefined
+let mockProviderInfo: { events: TriggerEvent[] } | undefined
+
+vi.mock('../store', () => ({
+ usePluginStore: (selector: (state: { detail: typeof mockDetail }) => typeof mockDetail) =>
+ selector({ detail: mockDetail }),
+}))
+
+vi.mock('@/service/use-triggers', () => ({
+ useTriggerProviderInfo: () => ({ data: mockProviderInfo }),
+}))
+
+vi.mock('./event-detail-drawer', () => ({
+ EventDetailDrawer: ({ onClose }: { onClose: () => void }) => (
+
+
+
+ ),
+}))
+
+describe('TriggerEventsList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDetail = { plugin_id: 'test-plugin', provider: 'test-provider' }
+ mockProviderInfo = { events: mockTriggerEvents }
+ })
+
+ describe('Rendering', () => {
+ it('should render event count', () => {
+ render()
+
+ expect(screen.getByText('1 events.event')).toBeInTheDocument()
+ })
+
+ it('should render event cards', () => {
+ render()
+
+ expect(screen.getByText('Event One')).toBeInTheDocument()
+ expect(screen.getByText('Event one description')).toBeInTheDocument()
+ })
+
+ it('should return null when no provider info', () => {
+ mockProviderInfo = undefined
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should return null when no events', () => {
+ mockProviderInfo = { events: [] }
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should return null when no detail', () => {
+ mockDetail = undefined
+ mockProviderInfo = undefined
+ const { container } = render()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should show detail drawer when event card clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByText('Event One'))
+
+ expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument()
+ })
+
+ it('should hide detail drawer when close clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByText('Event One'))
+ expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByTestId('close-drawer'))
+ expect(screen.queryByTestId('event-detail-drawer')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Multiple Events', () => {
+ it('should render multiple event cards', () => {
+ const secondEvent = {
+ name: 'event-2',
+ identity: {
+ author: 'author-2',
+ name: 'event-2',
+ label: { en_US: 'Event Two' },
+ },
+ description: { en_US: 'Event two description' },
+ parameters: [],
+ output_schema: {},
+ } as unknown as TriggerEvent
+
+ mockProviderInfo = {
+ events: [...mockTriggerEvents, secondEvent],
+ }
+ render()
+
+ expect(screen.getByText('Event One')).toBeInTheDocument()
+ expect(screen.getByText('Event Two')).toBeInTheDocument()
+ expect(screen.getByText('2 events.events')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/utils.spec.ts b/web/app/components/plugins/plugin-detail-panel/utils.spec.ts
new file mode 100644
index 0000000000..6c911d5ebd
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/utils.spec.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest'
+import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { NAME_FIELD } from './utils'
+
+describe('utils', () => {
+ describe('NAME_FIELD', () => {
+ it('should have correct type', () => {
+ expect(NAME_FIELD.type).toBe(FormTypeEnum.textInput)
+ })
+
+ it('should have correct name', () => {
+ expect(NAME_FIELD.name).toBe('name')
+ })
+
+ it('should have label translations', () => {
+ expect(NAME_FIELD.label).toBeDefined()
+ expect(NAME_FIELD.label.en_US).toBe('Endpoint Name')
+ expect(NAME_FIELD.label.zh_Hans).toBe('端点名称')
+ expect(NAME_FIELD.label.ja_JP).toBe('エンドポイント名')
+ expect(NAME_FIELD.label.pt_BR).toBe('Nome do ponto final')
+ })
+
+ it('should have placeholder translations', () => {
+ expect(NAME_FIELD.placeholder).toBeDefined()
+ expect(NAME_FIELD.placeholder.en_US).toBe('Endpoint Name')
+ expect(NAME_FIELD.placeholder.zh_Hans).toBe('端点名称')
+ expect(NAME_FIELD.placeholder.ja_JP).toBe('エンドポイント名')
+ expect(NAME_FIELD.placeholder.pt_BR).toBe('Nome do ponto final')
+ })
+
+ it('should be required', () => {
+ expect(NAME_FIELD.required).toBe(true)
+ })
+
+ it('should have empty default value', () => {
+ expect(NAME_FIELD.default).toBe('')
+ })
+
+ it('should have null help', () => {
+ expect(NAME_FIELD.help).toBeNull()
+ })
+
+ it('should have all required field properties', () => {
+ const requiredKeys = ['type', 'name', 'label', 'placeholder', 'required', 'default', 'help']
+ requiredKeys.forEach((key) => {
+ expect(NAME_FIELD).toHaveProperty(key)
+ })
+ })
+
+ it('should match expected structure', () => {
+ expect(NAME_FIELD).toEqual({
+ type: FormTypeEnum.textInput,
+ name: 'name',
+ label: {
+ en_US: 'Endpoint Name',
+ zh_Hans: '端点名称',
+ ja_JP: 'エンドポイント名',
+ pt_BR: 'Nome do ponto final',
+ },
+ placeholder: {
+ en_US: 'Endpoint Name',
+ zh_Hans: '端点名称',
+ ja_JP: 'エンドポイント名',
+ pt_BR: 'Nome do ponto final',
+ },
+ required: true,
+ default: '',
+ help: null,
+ })
+ })
+ })
+})