diff --git a/api/templates/invite_member_mail_template_en-US.html b/api/templates/invite_member_mail_template_en-US.html index a07c5f4b16..7b296519f0 100644 --- a/api/templates/invite_member_mail_template_en-US.html +++ b/api/templates/invite_member_mail_template_en-US.html @@ -83,7 +83,30 @@

Dear {{ to }},

{{ inviter_name }} is pleased to invite you to join our workspace on Dify, a platform specifically designed for LLM application development. On Dify, you can explore, create, and collaborate to build and operate AI applications.

Click the button below to log in to Dify and join the workspace.

-

Login Here

+
+ Login Here +

+ If the button doesn't work, copy and paste this link into your browser:
+ + {{ url }} + +

+

Best regards,

Dify Team

diff --git a/api/templates/invite_member_mail_template_zh-CN.html b/api/templates/invite_member_mail_template_zh-CN.html index 27709a3c6d..c05b3ddb67 100644 --- a/api/templates/invite_member_mail_template_zh-CN.html +++ b/api/templates/invite_member_mail_template_zh-CN.html @@ -83,7 +83,30 @@

尊敬的 {{ to }},

{{ inviter_name }} 现邀请您加入我们在 Dify 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 Dify 上,您可以探索、创造和合作,构建和运营 AI 应用。

点击下方按钮即可登录 Dify 并且加入空间。

-

在此登录

+
+ 在此登录 +

+ 如果按钮无法使用,请将以下链接复制到浏览器打开:
+ + {{ url }} + +

+

此致,

Dify 团队

diff --git a/api/templates/register_email_when_account_exist_template_en-US.html b/api/templates/register_email_when_account_exist_template_en-US.html index ac5042c274..e2bb99c989 100644 --- a/api/templates/register_email_when_account_exist_template_en-US.html +++ b/api/templates/register_email_when_account_exist_template_en-US.html @@ -115,7 +115,30 @@ We noticed you tried to sign up, but this email is already registered with an existing account. Please log in here:

- Log In +
+ Log In +

+ If the button doesn't work, copy and paste this link into your browser:
+ + {{ login_url }} + +

+

If you forgot your password, you can reset it here: Reset Password diff --git a/api/templates/register_email_when_account_exist_template_zh-CN.html b/api/templates/register_email_when_account_exist_template_zh-CN.html index 326b58343a..6a5bbd135b 100644 --- a/api/templates/register_email_when_account_exist_template_zh-CN.html +++ b/api/templates/register_email_when_account_exist_template_zh-CN.html @@ -115,7 +115,30 @@ 我们注意到您尝试注册,但此电子邮件已注册。 请在此登录:

- 登录 +
+ 登录 +

+ 如果按钮无法使用,请将以下链接复制到浏览器打开:
+ + {{ login_url }} + +

+

如果您忘记了密码,可以在此重置: 重置密码

diff --git a/api/templates/without-brand/invite_member_mail_template_en-US.html b/api/templates/without-brand/invite_member_mail_template_en-US.html index f9157284fa..687ece617a 100644 --- a/api/templates/without-brand/invite_member_mail_template_en-US.html +++ b/api/templates/without-brand/invite_member_mail_template_en-US.html @@ -92,12 +92,34 @@ platform specifically designed for LLM application development. On {{application_title}}, you can explore, create, and collaborate to build and operate AI applications.

Click the button below to log in to {{application_title}} and join the workspace.

-

Login Here

+
+ Login Here +

+ If the button doesn't work, copy and paste this link into your browser:
+ + {{ url }} + +

+

Best regards,

{{application_title}} Team

- \ No newline at end of file + diff --git a/api/templates/without-brand/invite_member_mail_template_zh-CN.html b/api/templates/without-brand/invite_member_mail_template_zh-CN.html index e787c90914..9ca1ef62cd 100644 --- a/api/templates/without-brand/invite_member_mail_template_zh-CN.html +++ b/api/templates/without-brand/invite_member_mail_template_zh-CN.html @@ -81,7 +81,30 @@

尊敬的 {{ to }},

{{ inviter_name }} 现邀请您加入我们在 {{application_title}} 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 {{application_title}} 上,您可以探索、创造和合作,构建和运营 AI 应用。

点击下方按钮即可登录 {{application_title}} 并且加入空间。

-

在此登录

+
+ 在此登录 +

+ 如果按钮无法使用,请将以下链接复制到浏览器打开:
+ + {{ url }} + +

+

此致,

{{application_title}} 团队

diff --git a/api/templates/without-brand/register_email_when_account_exist_template_en-US.html b/api/templates/without-brand/register_email_when_account_exist_template_en-US.html index 2e74956e14..2a26aac5b9 100644 --- a/api/templates/without-brand/register_email_when_account_exist_template_en-US.html +++ b/api/templates/without-brand/register_email_when_account_exist_template_en-US.html @@ -111,7 +111,30 @@ We noticed you tried to sign up, but this email is already registered with an existing account. Please log in here:

- Log In +
+ Log In +

+ If the button doesn't work, copy and paste this link into your browser:
+ + {{ login_url }} + +

+

If you forgot your password, you can reset it here: Reset Password diff --git a/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html b/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html index a315f9154d..b9f93eb0fc 100644 --- a/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html +++ b/api/templates/without-brand/register_email_when_account_exist_template_zh-CN.html @@ -111,7 +111,30 @@ 我们注意到您尝试注册,但此电子邮件已注册。 请在此登录:

- 登录 +
+ 登录 +

+ 如果按钮无法使用,请将以下链接复制到浏览器打开:
+ + {{ login_url }} + +

+

如果您忘记了密码,可以在此重置: 重置密码

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, + }) + }) + }) +})