From 92dbc94f2f3d99e9c24b8389520ee1443a326c4f Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Mon, 19 Jan 2026 14:40:32 +0800 Subject: [PATCH] test: add unit tests for plugin detail panel components including action lists, strategy lists, and endpoint management (#31053) Co-authored-by: CodingOnStar --- .../index.spec.tsx | 101 ++ .../retrieval-method-info/index.spec.tsx | 148 +++ .../detail/embedding/skeleton/index.spec.tsx | 46 + .../list/dataset-footer/index.spec.tsx | 30 + .../list/new-dataset-card/index.spec.tsx | 49 + .../settings/chunk-structure/index.spec.tsx | 85 ++ .../plugin-detail-panel/action-list.spec.tsx | 130 ++ .../agent-strategy-list.spec.tsx | 131 ++ .../datasource-action-list.spec.tsx | 104 ++ .../detail-header.spec.tsx | 1002 +++++++++++++++ .../endpoint-card.spec.tsx | 386 ++++++ .../endpoint-list.spec.tsx | 222 ++++ .../endpoint-modal.spec.tsx | 519 ++++++++ .../plugin-detail-panel/index.spec.tsx | 1144 +++++++++++++++++ .../plugin-detail-panel/model-list.spec.tsx | 103 ++ .../operation-dropdown.spec.tsx | 215 ++++ .../plugins/plugin-detail-panel/store.spec.ts | 461 +++++++ .../strategy-detail.spec.tsx | 203 +++ .../strategy-item.spec.tsx | 102 ++ .../create/common-modal.spec.tsx | 183 +++ .../subscription-list/create/index.spec.tsx | 209 +++ .../create/oauth-client.spec.tsx | 56 + .../trigger/event-detail-drawer.spec.tsx | 287 +++++ .../trigger/event-list.spec.tsx | 146 +++ .../plugins/plugin-detail-panel/utils.spec.ts | 72 ++ 25 files changed, 6134 insertions(+) create mode 100644 web/app/components/datasets/common/economical-retrieval-method-config/index.spec.tsx create mode 100644 web/app/components/datasets/common/retrieval-method-info/index.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-footer/index.spec.tsx create mode 100644 web/app/components/datasets/list/new-dataset-card/index.spec.tsx create mode 100644 web/app/components/datasets/settings/chunk-structure/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/action-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/agent-strategy-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/datasource-action-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/endpoint-card.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/endpoint-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/endpoint-modal.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/model-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/operation-dropdown.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/store.spec.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/strategy-detail.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/strategy-item.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/trigger/event-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/utils.spec.ts 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 }) => ( + {alt + ), +})) + +// 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, + }) + }) + }) +})