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() }) }) })