diff --git a/web/app/components/base/skeleton/index.tsx b/web/app/components/base/skeleton/index.tsx index 9cd7e3f09c..cbb5a3d7c3 100644 --- a/web/app/components/base/skeleton/index.tsx +++ b/web/app/components/base/skeleton/index.tsx @@ -36,7 +36,8 @@ export const SkeletonPoint: FC = (props) => {
·
) } -/** Usage +/** + * Usage * * * diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx index e9e8ec7525..d8a4fabac0 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.tsx @@ -6,20 +6,22 @@ type ModelDisplayProps = { } const ModelDisplay = ({ currentModel, modelId }: ModelDisplayProps) => { - return currentModel ? ( - - ) : ( -
-
- {modelId} -
-
- ) + return currentModel + ? ( + + ) + : ( +
+
+ {modelId} +
+
+ ) } export default ModelDisplay diff --git a/web/app/components/plugins/plugin-item/action.spec.tsx b/web/app/components/plugins/plugin-item/action.spec.tsx new file mode 100644 index 0000000000..9969357bb6 --- /dev/null +++ b/web/app/components/plugins/plugin-item/action.spec.tsx @@ -0,0 +1,937 @@ +import type { MetaData, PluginCategoryEnum } from '../types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Toast from '@/app/components/base/toast' + +// ==================== Imports (after mocks) ==================== + +import { PluginSource } from '../types' +import Action from './action' + +// ==================== Mock Setup ==================== + +// Use vi.hoisted to define mock functions that can be referenced in vi.mock +const { + mockUninstallPlugin, + mockFetchReleases, + mockCheckForUpdates, + mockSetShowUpdatePluginModal, + mockInvalidateInstalledPluginList, +} = vi.hoisted(() => ({ + mockUninstallPlugin: vi.fn(), + mockFetchReleases: vi.fn(), + mockCheckForUpdates: vi.fn(), + mockSetShowUpdatePluginModal: vi.fn(), + mockInvalidateInstalledPluginList: vi.fn(), +})) + +// Mock uninstall plugin service +vi.mock('@/service/plugins', () => ({ + uninstallPlugin: (id: string) => mockUninstallPlugin(id), +})) + +// Mock GitHub releases hook +vi.mock('../install-plugin/hooks', () => ({ + useGitHubReleases: () => ({ + fetchReleases: mockFetchReleases, + checkForUpdates: mockCheckForUpdates, + }), +})) + +// Mock modal context +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowUpdatePluginModal: mockSetShowUpdatePluginModal, + }), +})) + +// Mock invalidate installed plugin list +vi.mock('@/service/use-plugins', () => ({ + useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, +})) + +// Mock PluginInfo component - has complex dependencies (Modal, KeyValueItem) +vi.mock('../plugin-page/plugin-info', () => ({ + default: ({ repository, release, packageName, onHide }: { + repository: string + release: string + packageName: string + onHide: () => void + }) => ( +
+ +
+ ), +})) + +// Mock Tooltip - uses PortalToFollowElem which requires complex floating UI setup +// Simplified mock that just renders children with tooltip content accessible +vi.mock('../../base/tooltip', () => ({ + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( +
+ {children} +
+ ), +})) + +// Mock Confirm - uses createPortal which has issues in test environment +vi.mock('../../base/confirm', () => ({ + default: ({ isShow, title, content, onCancel, onConfirm, isLoading, isDisabled }: { + isShow: boolean + title: string + content: React.ReactNode + onCancel: () => void + onConfirm: () => void + isLoading: boolean + isDisabled: boolean + }) => { + if (!isShow) + return null + return ( +
+
{title}
+
{content}
+ + +
+ ) + }, +})) + +// ==================== Test Utilities ==================== + +type ActionProps = { + author: string + installationId: string + pluginUniqueIdentifier: string + pluginName: string + category: PluginCategoryEnum + usedInApps: number + isShowFetchNewVersion: boolean + isShowInfo: boolean + isShowDelete: boolean + onDelete: () => void + meta?: MetaData +} + +const createActionProps = (overrides: Partial = {}): ActionProps => ({ + author: 'test-author', + installationId: 'install-123', + pluginUniqueIdentifier: 'test-author/test-plugin@1.0.0', + pluginName: 'test-plugin', + category: 'tool' as PluginCategoryEnum, + usedInApps: 5, + isShowFetchNewVersion: false, + isShowInfo: false, + isShowDelete: true, + onDelete: vi.fn(), + meta: { + repo: 'test-author/test-plugin', + version: '1.0.0', + package: 'test-plugin.difypkg', + }, + ...overrides, +}) + +// ==================== Tests ==================== + +// Helper to find action buttons (real ActionButton component uses type="button") +const getActionButtons = () => screen.getAllByRole('button') +const queryActionButtons = () => screen.queryAllByRole('button') + +describe('Action Component', () => { + // Spy on Toast.notify - real component but we track calls + let toastNotifySpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + // Spy on Toast.notify and mock implementation to avoid DOM side effects + toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + mockUninstallPlugin.mockResolvedValue({ success: true }) + mockFetchReleases.mockResolvedValue([]) + mockCheckForUpdates.mockReturnValue({ + needUpdate: false, + toastProps: { type: 'info', message: 'Up to date' }, + }) + }) + + afterEach(() => { + toastNotifySpy.mockRestore() + }) + + // ==================== Rendering Tests ==================== + describe('Rendering', () => { + it('should render delete button when isShowDelete is true', () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + + // Assert + expect(getActionButtons()).toHaveLength(1) + }) + + it('should render fetch new version button when isShowFetchNewVersion is true', () => { + // Arrange + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowInfo: false, + isShowDelete: false, + }) + + // Act + render() + + // Assert + expect(getActionButtons()).toHaveLength(1) + }) + + it('should render info button when isShowInfo is true', () => { + // Arrange + const props = createActionProps({ + isShowFetchNewVersion: false, + isShowInfo: true, + isShowDelete: false, + }) + + // Act + render() + + // Assert + expect(getActionButtons()).toHaveLength(1) + }) + + it('should render all buttons when all flags are true', () => { + // Arrange + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowInfo: true, + isShowDelete: true, + }) + + // Act + render() + + // Assert + expect(getActionButtons()).toHaveLength(3) + }) + + it('should render no buttons when all flags are false', () => { + // Arrange + const props = createActionProps({ + isShowFetchNewVersion: false, + isShowInfo: false, + isShowDelete: false, + }) + + // Act + render() + + // Assert + expect(queryActionButtons()).toHaveLength(0) + }) + + it('should render tooltips for each button', () => { + // Arrange + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowInfo: true, + isShowDelete: true, + }) + + // Act + render() + + // Assert + const tooltips = screen.getAllByTestId('tooltip') + expect(tooltips).toHaveLength(3) + }) + }) + + // ==================== Delete Functionality Tests ==================== + describe('Delete Functionality', () => { + it('should show delete confirm modal when delete button is clicked', () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + expect(screen.getByTestId('confirm-title')).toHaveTextContent('plugin.action.delete') + }) + + it('should display plugin name in delete confirm content', () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + pluginName: 'my-awesome-plugin', + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument() + }) + + it('should hide confirm modal when cancel is clicked', () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('confirm-cancel')) + + // Assert + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + }) + + it('should call uninstallPlugin when confirm is clicked', async () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + installationId: 'install-456', + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // Assert + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('install-456') + }) + }) + + it('should call onDelete callback after successful uninstall', async () => { + // Arrange + mockUninstallPlugin.mockResolvedValue({ success: true }) + const onDelete = vi.fn() + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + onDelete, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // Assert + await waitFor(() => { + expect(onDelete).toHaveBeenCalled() + }) + }) + + it('should not call onDelete if uninstall fails', async () => { + // Arrange + mockUninstallPlugin.mockResolvedValue({ success: false }) + const onDelete = vi.fn() + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + onDelete, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // Assert + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalled() + }) + expect(onDelete).not.toHaveBeenCalled() + }) + + it('should handle uninstall error gracefully', async () => { + // Arrange + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockUninstallPlugin.mockRejectedValue(new Error('Network error')) + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // Assert + await waitFor(() => { + expect(consoleError).toHaveBeenCalledWith('uninstallPlugin error', expect.any(Error)) + }) + + consoleError.mockRestore() + }) + + it('should show loading state during deletion', async () => { + // Arrange + let resolveUninstall: (value: { success: boolean }) => void + mockUninstallPlugin.mockReturnValue( + new Promise((resolve) => { + resolveUninstall = resolve + }), + ) + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // Assert - Loading state + await waitFor(() => { + expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true') + }) + + // Resolve and check modal closes + resolveUninstall!({ success: true }) + await waitFor(() => { + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + }) + }) + }) + + // ==================== Plugin Info Tests ==================== + describe('Plugin Info', () => { + it('should show plugin info modal when info button is clicked', () => { + // Arrange + const props = createActionProps({ + isShowInfo: true, + isShowDelete: false, + isShowFetchNewVersion: false, + meta: { + repo: 'owner/repo-name', + version: '2.0.0', + package: 'my-package.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument() + expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-repo', 'owner/repo-name') + expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-release', '2.0.0') + expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-package', 'my-package.difypkg') + }) + + it('should hide plugin info modal when close is clicked', () => { + // Arrange + const props = createActionProps({ + isShowInfo: true, + isShowDelete: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('close-plugin-info')) + + // Assert + expect(screen.queryByTestId('plugin-info-modal')).not.toBeInTheDocument() + }) + }) + + // ==================== Check for Updates Tests ==================== + describe('Check for Updates', () => { + it('should fetch releases when check for updates button is clicked', async () => { + // Arrange + mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }]) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + meta: { + repo: 'owner/repo', + version: '1.0.0', + package: 'pkg.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo') + }) + }) + + it('should use author and pluginName as fallback for empty repo parts', async () => { + // Arrange + mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }]) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + author: 'fallback-author', + pluginName: 'fallback-plugin', + meta: { + repo: '/', // Results in empty parts after split + version: '1.0.0', + package: 'pkg.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalledWith('fallback-author', 'fallback-plugin') + }) + }) + + it('should not proceed if no releases are fetched', async () => { + // Arrange + mockFetchReleases.mockResolvedValue([]) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalled() + }) + expect(mockCheckForUpdates).not.toHaveBeenCalled() + }) + + it('should show toast notification after checking for updates', async () => { + // Arrange + mockFetchReleases.mockResolvedValue([{ version: '2.0.0' }]) + mockCheckForUpdates.mockReturnValue({ + needUpdate: false, + toastProps: { type: 'success', message: 'Already up to date' }, + }) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert - Toast.notify is called with the toast props + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith({ type: 'success', message: 'Already up to date' }) + }) + }) + + it('should show update modal when update is available', async () => { + // Arrange + const releases = [{ version: '2.0.0' }] + mockFetchReleases.mockResolvedValue(releases) + mockCheckForUpdates.mockReturnValue({ + needUpdate: true, + toastProps: { type: 'info', message: 'Update available' }, + }) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + pluginUniqueIdentifier: 'test-id', + category: 'model' as PluginCategoryEnum, + meta: { + repo: 'owner/repo', + version: '1.0.0', + package: 'pkg.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + await waitFor(() => { + expect(mockSetShowUpdatePluginModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + type: PluginSource.github, + category: 'model', + github: expect.objectContaining({ + originalPackageInfo: expect.objectContaining({ + id: 'test-id', + repo: 'owner/repo', + version: '1.0.0', + package: 'pkg.difypkg', + releases, + }), + }), + }), + }), + ) + }) + }) + + it('should call invalidateInstalledPluginList on save callback', async () => { + // Arrange + const releases = [{ version: '2.0.0' }] + mockFetchReleases.mockResolvedValue(releases) + mockCheckForUpdates.mockReturnValue({ + needUpdate: true, + toastProps: { type: 'info', message: 'Update available' }, + }) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Wait for modal to be called + await waitFor(() => { + expect(mockSetShowUpdatePluginModal).toHaveBeenCalled() + }) + + // Invoke the callback + const call = mockSetShowUpdatePluginModal.mock.calls[0][0] + call.onSaveCallback() + + // Assert + expect(mockInvalidateInstalledPluginList).toHaveBeenCalled() + }) + + it('should check updates with current version', async () => { + // Arrange + const releases = [{ version: '2.0.0' }, { version: '1.5.0' }] + mockFetchReleases.mockResolvedValue(releases) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + meta: { + repo: 'owner/repo', + version: '1.0.0', + package: 'pkg.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + await waitFor(() => { + expect(mockCheckForUpdates).toHaveBeenCalledWith(releases, '1.0.0') + }) + }) + }) + + // ==================== Callback Stability Tests ==================== + describe('Callback Stability (useCallback)', () => { + it('should have stable handleDelete callback with same dependencies', async () => { + // Arrange + mockUninstallPlugin.mockResolvedValue({ success: true }) + const onDelete = vi.fn() + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + onDelete, + installationId: 'stable-install-id', + }) + + // Act - First render and delete + const { rerender } = render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id') + }) + + // Re-render with same props + mockUninstallPlugin.mockClear() + rerender() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id') + }) + }) + + it('should update handleDelete when installationId changes', async () => { + // Arrange + mockUninstallPlugin.mockResolvedValue({ success: true }) + const props1 = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + installationId: 'install-1', + }) + const props2 = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + installationId: 'install-2', + }) + + // Act + const { rerender } = render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('install-1') + }) + + mockUninstallPlugin.mockClear() + rerender() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('install-2') + }) + }) + + it('should update handleDelete when onDelete changes', async () => { + // Arrange + mockUninstallPlugin.mockResolvedValue({ success: true }) + const onDelete1 = vi.fn() + const onDelete2 = vi.fn() + const props1 = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + onDelete: onDelete1, + }) + const props2 = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + onDelete: onDelete2, + }) + + // Act + const { rerender } = render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(onDelete1).toHaveBeenCalled() + }) + expect(onDelete2).not.toHaveBeenCalled() + + rerender() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(onDelete2).toHaveBeenCalled() + }) + }) + }) + + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it('should handle undefined meta for info display', () => { + // Arrange - meta is required for info, but test defensive behavior + const props = createActionProps({ + isShowInfo: false, + isShowDelete: true, + isShowFetchNewVersion: false, + meta: undefined, + }) + + // Act & Assert - Should not crash + expect(() => render()).not.toThrow() + }) + + it('should handle empty repo string', async () => { + // Arrange + mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }]) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + author: 'fallback-owner', + pluginName: 'fallback-repo', + meta: { + repo: '', + version: '1.0.0', + package: 'pkg.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert - Should use author and pluginName as fallback + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalledWith('fallback-owner', 'fallback-repo') + }) + }) + + it('should handle concurrent delete requests gracefully', async () => { + // Arrange + let resolveFirst: (value: { success: boolean }) => void + const firstPromise = new Promise<{ success: boolean }>((resolve) => { + resolveFirst = resolve + }) + mockUninstallPlugin.mockReturnValueOnce(firstPromise) + + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // The confirm button should be disabled during deletion + expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true') + expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-disabled', 'true') + + // Resolve the deletion + resolveFirst!({ success: true }) + + await waitFor(() => { + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + }) + }) + + it('should handle special characters in plugin name', () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + pluginName: 'plugin-with-special@chars#123', + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + expect(screen.getByText('plugin-with-special@chars#123')).toBeInTheDocument() + }) + }) + + // ==================== React.memo Tests ==================== + describe('React.memo Behavior', () => { + it('should be wrapped with React.memo', () => { + // Assert + expect(Action).toBeDefined() + expect((Action as any).$$typeof?.toString()).toContain('Symbol') + }) + }) + + // ==================== Prop Variations ==================== + describe('Prop Variations', () => { + it('should handle all category types', () => { + // Arrange + const categories = ['tool', 'model', 'extension', 'agent-strategy', 'datasource'] as PluginCategoryEnum[] + + categories.forEach((category) => { + const props = createActionProps({ + category, + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + expect(() => render()).not.toThrow() + }) + }) + + it('should handle different usedInApps values', () => { + // Arrange + const values = [0, 1, 5, 100] + + values.forEach((usedInApps) => { + const props = createActionProps({ + usedInApps, + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + expect(() => render()).not.toThrow() + }) + }) + + it('should handle combination of multiple action buttons', () => { + // Arrange - Test various combinations + const combinations = [ + { isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: false }, + { isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: false }, + { isShowFetchNewVersion: false, isShowInfo: false, isShowDelete: true }, + { isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: false }, + { isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: true }, + { isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: true }, + { isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: true }, + ] + + combinations.forEach((flags) => { + const props = createActionProps(flags) + const expectedCount = [flags.isShowFetchNewVersion, flags.isShowInfo, flags.isShowDelete].filter(Boolean).length + + const { unmount } = render() + const buttons = queryActionButtons() + expect(buttons).toHaveLength(expectedCount) + unmount() + }) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-item/index.spec.tsx b/web/app/components/plugins/plugin-item/index.spec.tsx new file mode 100644 index 0000000000..ae76e64c46 --- /dev/null +++ b/web/app/components/plugins/plugin-item/index.spec.tsx @@ -0,0 +1,1016 @@ +import type { PluginDeclaration, PluginDetail } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, PluginSource } from '../types' + +// ==================== Imports (after mocks) ==================== + +import PluginItem from './index' + +// ==================== Mock Setup ==================== + +// Mock theme hook +const mockTheme = vi.fn(() => 'light') +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: mockTheme() }), +})) + +// Mock i18n render hook +const mockGetValueFromI18nObject = vi.fn((obj: Record) => obj?.en_US || '') +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => mockGetValueFromI18nObject, +})) + +// Mock categories hook +const mockCategoriesMap: Record = { + 'tool': { name: 'tool', label: 'Tools' }, + 'model': { name: 'model', label: 'Models' }, + 'extension': { name: 'extension', label: 'Extensions' }, + 'agent-strategy': { name: 'agent-strategy', label: 'Agents' }, + 'datasource': { name: 'datasource', label: 'Data Sources' }, +} +vi.mock('../hooks', () => ({ + useCategories: () => ({ + categories: Object.values(mockCategoriesMap), + categoriesMap: mockCategoriesMap, + }), +})) + +// Mock plugin page context +const mockCurrentPluginID = vi.fn((): string | undefined => undefined) +const mockSetCurrentPluginID = vi.fn() +vi.mock('../plugin-page/context', () => ({ + usePluginPageContext: (selector: (v: any) => any) => { + const context = { + currentPluginID: mockCurrentPluginID(), + setCurrentPluginID: mockSetCurrentPluginID, + } + return selector(context) + }, +})) + +// Mock refresh plugin list hook +const mockRefreshPluginList = vi.fn() +vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({ + default: () => ({ refreshPluginList: mockRefreshPluginList }), +})) + +// Mock app context +const mockLangGeniusVersionInfo = vi.fn(() => ({ + current_version: '1.0.0', +})) +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + langGeniusVersionInfo: mockLangGeniusVersionInfo(), + }), +})) + +// Mock global public store +const mockEnableMarketplace = vi.fn(() => true) +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (s: any) => any) => + selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace() } }), +})) + +// Mock Action component +vi.mock('./action', () => ({ + default: ({ onDelete, pluginName }: { onDelete: () => void, pluginName: string }) => ( +
+ +
+ ), +})) + +// Mock child components +vi.mock('../card/base/corner-mark', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('../card/base/title', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})) + +vi.mock('../card/base/description', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('../card/base/org-info', () => ({ + default: ({ orgName, packageName }: { orgName: string, packageName: string }) => ( +
+ {orgName} + / + {packageName} +
+ ), +})) + +vi.mock('../base/badges/verified', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('../../base/badge', () => ({ + default: ({ text, hasRedCornerMark }: { text: string, hasRedCornerMark?: boolean }) => ( +
{text}
+ ), +})) + +// ==================== Test Utilities ==================== + +const createPluginDeclaration = (overrides: Partial = {}): PluginDeclaration => ({ + plugin_unique_identifier: 'test-plugin-id', + version: '1.0.0', + author: 'test-author', + icon: 'test-icon.png', + icon_dark: 'test-icon-dark.png', + name: 'test-plugin', + category: PluginCategoryEnum.tool, + label: { en_US: 'Test Plugin' } as any, + description: { en_US: 'Test plugin description' } as any, + created_at: '2024-01-01', + resource: null, + plugins: null, + verified: false, + endpoint: {} as any, + model: null, + tags: [], + agent_strategy: null, + meta: { + version: '1.0.0', + minimum_dify_version: '0.5.0', + }, + trigger: {} as any, + ...overrides, +}) + +const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({ + id: 'plugin-1', + created_at: '2024-01-01', + updated_at: '2024-01-01', + name: 'test-plugin', + plugin_id: 'plugin-1', + plugin_unique_identifier: 'test-author/test-plugin@1.0.0', + declaration: createPluginDeclaration(), + 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-author/test-plugin@1.0.0', + source: PluginSource.marketplace, + meta: { + repo: 'test-author/test-plugin', + version: '1.0.0', + package: 'test-plugin.difypkg', + }, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +// ==================== Tests ==================== + +describe('PluginItem', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme.mockReturnValue('light') + mockCurrentPluginID.mockReturnValue(undefined) + mockEnableMarketplace.mockReturnValue(true) + mockLangGeniusVersionInfo.mockReturnValue({ current_version: '1.0.0' }) + mockGetValueFromI18nObject.mockImplementation((obj: Record) => obj?.en_US || '') + }) + + // ==================== Rendering Tests ==================== + describe('Rendering', () => { + it('should render plugin item with basic info', () => { + // Arrange + const plugin = createPluginDetail() + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-title')).toBeInTheDocument() + expect(screen.getByTestId('plugin-description')).toBeInTheDocument() + expect(screen.getByTestId('corner-mark')).toBeInTheDocument() + expect(screen.getByTestId('version-badge')).toBeInTheDocument() + }) + + it('should render plugin icon', () => { + // Arrange + const plugin = createPluginDetail() + + // Act + render() + + // Assert + const img = screen.getByRole('img') + expect(img).toHaveAttribute('alt', `plugin-${plugin.plugin_unique_identifier}-logo`) + }) + + it('should render category label in corner mark', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('corner-mark')).toHaveTextContent('Models') + }) + + it('should apply custom className', () => { + // Arrange + const plugin = createPluginDetail() + + // Act + const { container } = render() + + // Assert + const innerDiv = container.querySelector('.custom-class') + expect(innerDiv).toBeInTheDocument() + }) + }) + + // ==================== Plugin Sources Tests ==================== + describe('Plugin Sources', () => { + it('should render GitHub source with repo link', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: '1.0.0', package: 'pkg.difypkg' }, + }) + + // Act + render() + + // Assert + const githubLink = screen.getByRole('link') + expect(githubLink).toHaveAttribute('href', 'https://github.com/owner/repo') + expect(screen.getByText('GitHub')).toBeInTheDocument() + }) + + it('should render marketplace source with link when enabled', () => { + // Arrange + mockEnableMarketplace.mockReturnValue(true) + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + declaration: createPluginDeclaration({ author: 'test-author', name: 'test-plugin' }), + }) + + // Act + render() + + // Assert + expect(screen.getByText('marketplace')).toBeInTheDocument() + }) + + it('should render local source indicator', () => { + // Arrange + const plugin = createPluginDetail({ source: PluginSource.local }) + + // Act + render() + + // Assert + expect(screen.getByText('Local Plugin')).toBeInTheDocument() + }) + + it('should render debugging source indicator', () => { + // Arrange + const plugin = createPluginDetail({ source: PluginSource.debugging }) + + // Act + render() + + // Assert + expect(screen.getByText('Debugging Plugin')).toBeInTheDocument() + }) + + it('should show org info for GitHub source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + declaration: createPluginDeclaration({ author: 'github-author' }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'github-author') + }) + + it('should show org info for marketplace source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + declaration: createPluginDeclaration({ author: 'marketplace-author' }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'marketplace-author') + }) + + it('should not show org info for local source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.local, + declaration: createPluginDeclaration({ author: 'local-author' }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', '') + }) + }) + + // ==================== Extension Category Tests ==================== + describe('Extension Category', () => { + it('should show endpoints info for extension category', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.extension }), + endpoints_active: 3, + }) + + // Act + render() + + // Assert - The translation includes interpolation + expect(screen.getByText(/plugin\.endpointsEnabled/)).toBeInTheDocument() + }) + + it('should not show endpoints info for non-extension category', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + endpoints_active: 3, + }) + + // Act + render() + + // Assert + expect(screen.queryByText(/plugin\.endpointsEnabled/)).not.toBeInTheDocument() + }) + }) + + // ==================== Version Compatibility Tests ==================== + describe('Version Compatibility', () => { + it('should show warning icon when Dify version is not compatible', () => { + // Arrange + mockLangGeniusVersionInfo.mockReturnValue({ current_version: '0.3.0' }) + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + meta: { version: '1.0.0', minimum_dify_version: '0.5.0' }, + }), + }) + + // Act + const { container } = render() + + // Assert - Warning icon should be rendered + const warningIcon = container.querySelector('.text-text-accent') + expect(warningIcon).toBeInTheDocument() + }) + + it('should not show warning when Dify version is compatible', () => { + // Arrange + mockLangGeniusVersionInfo.mockReturnValue({ current_version: '1.0.0' }) + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + meta: { version: '1.0.0', minimum_dify_version: '0.5.0' }, + }), + }) + + // Act + const { container } = render() + + // Assert + const warningIcon = container.querySelector('.text-text-accent') + expect(warningIcon).not.toBeInTheDocument() + }) + + it('should handle missing current_version gracefully', () => { + // Arrange + mockLangGeniusVersionInfo.mockReturnValue({ current_version: '' }) + const plugin = createPluginDetail() + + // Act + const { container } = render() + + // Assert - Should not crash and not show warning + const warningIcon = container.querySelector('.text-text-accent') + expect(warningIcon).not.toBeInTheDocument() + }) + + it('should handle missing minimum_dify_version gracefully', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + meta: { version: '1.0.0' }, + }), + }) + + // Act + const { container } = render() + + // Assert - Should not crash and not show warning + const warningIcon = container.querySelector('.text-text-accent') + expect(warningIcon).not.toBeInTheDocument() + }) + }) + + // ==================== Deprecated Plugin Tests ==================== + describe('Deprecated Plugin', () => { + it('should show deprecated indicator for deprecated marketplace plugin', () => { + // Arrange + mockEnableMarketplace.mockReturnValue(true) + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'deleted', + deprecated_reason: 'Plugin is no longer maintained', + }) + + // Act + render() + + // Assert + expect(screen.getByText('plugin.deprecated')).toBeInTheDocument() + }) + + it('should show background effect for deprecated plugin', () => { + // Arrange + mockEnableMarketplace.mockReturnValue(true) + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'deleted', + deprecated_reason: 'Plugin is deprecated', + }) + + // Act + const { container } = render() + + // Assert + const bgEffect = container.querySelector('.blur-\\[120px\\]') + expect(bgEffect).toBeInTheDocument() + }) + + it('should not show deprecated indicator for active plugin', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'active', + deprecated_reason: '', + }) + + // Act + render() + + // Assert + expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument() + }) + + it('should not show deprecated indicator for non-marketplace source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + status: 'deleted', + deprecated_reason: 'Some reason', + }) + + // Act + render() + + // Assert + expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument() + }) + + it('should not show deprecated when marketplace is disabled', () => { + // Arrange + mockEnableMarketplace.mockReturnValue(false) + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'deleted', + deprecated_reason: 'Some reason', + }) + + // Act + render() + + // Assert + expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument() + }) + }) + + // ==================== Verified Badge Tests ==================== + describe('Verified Badge', () => { + it('should show verified badge for verified plugin', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ verified: true }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('verified-badge')).toBeInTheDocument() + }) + + it('should not show verified badge for unverified plugin', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ verified: false }), + }) + + // Act + render() + + // Assert + expect(screen.queryByTestId('verified-badge')).not.toBeInTheDocument() + }) + }) + + // ==================== Version Badge Tests ==================== + describe('Version Badge', () => { + it('should show version from meta for GitHub source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + version: '2.0.0', + meta: { repo: 'owner/repo', version: '1.5.0', package: 'pkg' }, + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('version-badge')).toHaveTextContent('1.5.0') + }) + + it('should show version from plugin for marketplace source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + version: '2.0.0', + meta: { repo: 'owner/repo', version: '1.5.0', package: 'pkg' }, + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('version-badge')).toHaveTextContent('2.0.0') + }) + + it('should show update indicator when new version available', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + version: '1.0.0', + latest_version: '2.0.0', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'true') + }) + + it('should not show update indicator when version is latest', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + version: '1.0.0', + latest_version: '1.0.0', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'false') + }) + + it('should not show update indicator for non-marketplace source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + version: '1.0.0', + latest_version: '2.0.0', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'false') + }) + }) + + // ==================== User Interactions Tests ==================== + describe('User Interactions', () => { + it('should call setCurrentPluginID when plugin is clicked', () => { + // Arrange + const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) + + // Act + const { container } = render() + const pluginContainer = container.firstChild as HTMLElement + fireEvent.click(pluginContainer) + + // Assert + expect(mockSetCurrentPluginID).toHaveBeenCalledWith('test-plugin-id') + }) + + it('should highlight selected plugin', () => { + // Arrange + mockCurrentPluginID.mockReturnValue('test-plugin-id') + const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) + + // Act + const { container } = render() + + // Assert + const pluginContainer = container.firstChild as HTMLElement + expect(pluginContainer).toHaveClass('border-components-option-card-option-selected-border') + }) + + it('should not highlight unselected plugin', () => { + // Arrange + mockCurrentPluginID.mockReturnValue('other-plugin-id') + const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) + + // Act + const { container } = render() + + // Assert + const pluginContainer = container.firstChild as HTMLElement + expect(pluginContainer).not.toHaveClass('border-components-option-card-option-selected-border') + }) + + it('should stop propagation when action area is clicked', () => { + // Arrange + const plugin = createPluginDetail() + + // Act + render() + const actionArea = screen.getByTestId('plugin-action').parentElement + fireEvent.click(actionArea!) + + // Assert - setCurrentPluginID should not be called + expect(mockSetCurrentPluginID).not.toHaveBeenCalled() + }) + }) + + // ==================== Delete Callback Tests ==================== + describe('Delete Callback', () => { + it('should call refreshPluginList when delete is triggered', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + }) + + // Act + render() + fireEvent.click(screen.getByTestId('delete-button')) + + // Assert + expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.tool }) + }) + + it('should pass correct category to refreshPluginList', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }), + }) + + // Act + render() + fireEvent.click(screen.getByTestId('delete-button')) + + // Assert + expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.model }) + }) + }) + + // ==================== Theme Tests ==================== + describe('Theme Support', () => { + it('should use dark icon when theme is dark and dark icon exists', () => { + // Arrange + mockTheme.mockReturnValue('dark') + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + icon: 'light-icon.png', + icon_dark: 'dark-icon.png', + }), + }) + + // Act + render() + + // Assert + const img = screen.getByRole('img') + expect(img.getAttribute('src')).toContain('dark-icon.png') + }) + + it('should use light icon when theme is light', () => { + // Arrange + mockTheme.mockReturnValue('light') + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + icon: 'light-icon.png', + icon_dark: 'dark-icon.png', + }), + }) + + // Act + render() + + // Assert + const img = screen.getByRole('img') + expect(img.getAttribute('src')).toContain('light-icon.png') + }) + + it('should use light icon when dark icon is not available', () => { + // Arrange + mockTheme.mockReturnValue('dark') + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + icon: 'light-icon.png', + icon_dark: undefined, + }), + }) + + // Act + render() + + // Assert + const img = screen.getByRole('img') + expect(img.getAttribute('src')).toContain('light-icon.png') + }) + + it('should use external URL directly for icon', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + icon: 'https://example.com/icon.png', + }), + }) + + // Act + render() + + // Assert + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', 'https://example.com/icon.png') + }) + }) + + // ==================== Memoization Tests ==================== + describe('Memoization', () => { + it('should memoize orgName based on source and author', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + declaration: createPluginDeclaration({ author: 'test-author' }), + }) + + // Act + const { rerender } = render() + + // First render should show author + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'test-author') + + // Re-render with same plugin + rerender() + + // Should still show same author + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'test-author') + }) + + it('should update orgName when source changes', () => { + // Arrange + const githubPlugin = createPluginDetail({ + source: PluginSource.github, + declaration: createPluginDeclaration({ author: 'github-author' }), + }) + const localPlugin = createPluginDetail({ + source: PluginSource.local, + declaration: createPluginDeclaration({ author: 'local-author' }), + }) + + // Act + const { rerender } = render() + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'github-author') + + rerender() + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', '') + }) + + it('should memoize isDeprecated based on status and deprecated_reason', () => { + // Arrange + mockEnableMarketplace.mockReturnValue(true) + const activePlugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'active', + deprecated_reason: '', + }) + const deprecatedPlugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'deleted', + deprecated_reason: 'Deprecated', + }) + + // Act + const { rerender } = render() + expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument() + + rerender() + expect(screen.getByText('plugin.deprecated')).toBeInTheDocument() + }) + }) + + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it('should handle empty icon gracefully', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ icon: '' }), + }) + + // Act & Assert - Should not throw when icon is empty + expect(() => render()).not.toThrow() + + // The img element should still be rendered + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + }) + + it('should handle missing meta for non-GitHub source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.local, + meta: undefined, + }) + + // Act & Assert - Should not throw + expect(() => render()).not.toThrow() + }) + + it('should handle empty label gracefully', () => { + // Arrange + mockGetValueFromI18nObject.mockReturnValue('') + const plugin = createPluginDetail() + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-title')).toHaveTextContent('') + }) + + it('should handle zero endpoints_active', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.extension }), + endpoints_active: 0, + }) + + // Act + render() + + // Assert - Should still render endpoints info with zero + expect(screen.getByText(/plugin\.endpointsEnabled/)).toBeInTheDocument() + }) + + it('should handle null latest_version', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + version: '1.0.0', + latest_version: null as any, + }) + + // Act + render() + + // Assert - Should not show update indicator + expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'false') + }) + }) + + // ==================== Prop Variations ==================== + describe('Prop Variations', () => { + it('should render correctly with minimal required props', () => { + // Arrange + const plugin = createPluginDetail() + + // Act & Assert + expect(() => render()).not.toThrow() + }) + + it('should handle different category types', () => { + // Arrange + const categories = [ + PluginCategoryEnum.tool, + PluginCategoryEnum.model, + PluginCategoryEnum.extension, + PluginCategoryEnum.agent, + PluginCategoryEnum.datasource, + ] + + categories.forEach((category) => { + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category }), + }) + + // Act & Assert + expect(() => render()).not.toThrow() + }) + }) + + it('should handle all source types', () => { + // Arrange + const sources = [ + PluginSource.marketplace, + PluginSource.github, + PluginSource.local, + PluginSource.debugging, + ] + + sources.forEach((source) => { + const plugin = createPluginDetail({ source }) + + // Act & Assert + expect(() => render()).not.toThrow() + }) + }) + }) + + // ==================== Callback Stability Tests ==================== + describe('Callback Stability', () => { + it('should have stable handleDelete callback', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + }) + + // Act + const { rerender } = render() + fireEvent.click(screen.getByTestId('delete-button')) + const firstCallArgs = mockRefreshPluginList.mock.calls[0] + + mockRefreshPluginList.mockClear() + rerender() + fireEvent.click(screen.getByTestId('delete-button')) + const secondCallArgs = mockRefreshPluginList.mock.calls[0] + + // Assert - Both calls should have same arguments + expect(firstCallArgs).toEqual(secondCallArgs) + }) + + it('should update handleDelete when category changes', () => { + // Arrange + const toolPlugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + }) + const modelPlugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }), + }) + + // Act + const { rerender } = render() + fireEvent.click(screen.getByTestId('delete-button')) + expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.tool }) + + mockRefreshPluginList.mockClear() + rerender() + fireEvent.click(screen.getByTestId('delete-button')) + expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.model }) + }) + }) + + // ==================== React.memo Tests ==================== + describe('React.memo Behavior', () => { + it('should be wrapped with React.memo', () => { + // Arrange & Assert + // The component is exported as React.memo(PluginItem) + // We can verify by checking the displayName or type + expect(PluginItem).toBeDefined() + // React.memo components have a $$typeof property + expect((PluginItem as any).$$typeof?.toString()).toContain('Symbol') + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/empty/index.spec.tsx b/web/app/components/plugins/plugin-page/empty/index.spec.tsx new file mode 100644 index 0000000000..0084fc6c3d --- /dev/null +++ b/web/app/components/plugins/plugin-page/empty/index.spec.tsx @@ -0,0 +1,572 @@ +import type { FilterState } from '../filter-management' +import type { SystemFeatures } from '@/types/feature' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defaultSystemFeatures, InstallationScope } from '@/types/feature' + +// ==================== Imports (after mocks) ==================== + +import Empty from './index' + +// ==================== Mock Setup ==================== + +// Use vi.hoisted to define ALL mock state and functions +const { + mockSetActiveTab, + mockUseInstalledPluginList, + mockState, +} = vi.hoisted(() => { + const state = { + filters: { + categories: [] as string[], + tags: [] as string[], + searchQuery: '', + } as FilterState, + systemFeatures: { + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: 'all' as const, + restrict_to_marketplace_only: false, + }, + } as Partial, + pluginList: { plugins: [] as Array<{ id: string }> } as { plugins: Array<{ id: string }> } | undefined, + } + return { + mockSetActiveTab: vi.fn(), + mockUseInstalledPluginList: vi.fn(() => ({ data: state.pluginList })), + mockState: state, + } +}) + +// Mock plugin page context +vi.mock('../context', () => ({ + usePluginPageContext: (selector: (value: any) => any) => { + const contextValue = { + filters: mockState.filters, + setActiveTab: mockSetActiveTab, + } + return selector(contextValue) + }, +})) + +// Mock global public store (Zustand store) +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: any) => any) => { + return selector({ + systemFeatures: { + ...defaultSystemFeatures, + ...mockState.systemFeatures, + }, + }) + }, +})) + +// Mock useInstalledPluginList hook +vi.mock('@/service/use-plugins', () => ({ + useInstalledPluginList: () => mockUseInstalledPluginList(), +})) + +// Mock InstallFromGitHub component +vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({ + default: ({ onClose }: { onSuccess: () => void, onClose: () => void }) => ( +
+ + +
+ ), +})) + +// Mock InstallFromLocalPackage component +vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({ + default: ({ file, onClose }: { file: File, onSuccess: () => void, onClose: () => void }) => ( +
+ + +
+ ), +})) + +// Mock Line component +vi.mock('../../marketplace/empty/line', () => ({ + default: ({ className }: { className?: string }) =>
, +})) + +// ==================== Test Utilities ==================== + +const resetMockState = () => { + mockState.filters = { categories: [], tags: [], searchQuery: '' } + mockState.systemFeatures = { + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + } + mockState.pluginList = { plugins: [] } + mockUseInstalledPluginList.mockReturnValue({ data: mockState.pluginList }) +} + +const setMockFilters = (filters: Partial) => { + mockState.filters = { ...mockState.filters, ...filters } +} + +const setMockSystemFeatures = (features: Partial) => { + mockState.systemFeatures = { ...mockState.systemFeatures, ...features } +} + +const setMockPluginList = (list: { plugins: Array<{ id: string }> } | undefined) => { + mockState.pluginList = list + mockUseInstalledPluginList.mockReturnValue({ data: list }) +} + +const createMockFile = (name: string, type = 'application/octet-stream'): File => { + return new File(['test'], name, { type }) +} + +// Helper to wait for useEffect to complete (single tick) +const flushEffects = async () => { + await act(async () => {}) +} + +// ==================== Tests ==================== + +describe('Empty Component', () => { + beforeEach(() => { + vi.clearAllMocks() + resetMockState() + }) + + // ==================== Rendering Tests ==================== + describe('Rendering', () => { + it('should render basic structure correctly', async () => { + // Arrange & Act + const { container } = render() + await flushEffects() + + // Assert - file input + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + expect(fileInput).toBeInTheDocument() + expect(fileInput.style.display).toBe('none') + expect(fileInput.accept).toBe('.difypkg,.difybndl') + + // Assert - skeleton cards + const skeletonCards = container.querySelectorAll('.rounded-xl.bg-components-card-bg') + expect(skeletonCards).toHaveLength(20) + + // Assert - group icon container + const iconContainer = document.querySelector('.size-14') + expect(iconContainer).toBeInTheDocument() + + // Assert - line components + const lines = screen.getAllByTestId('line-component') + expect(lines).toHaveLength(4) + }) + }) + + // ==================== Text Display Tests (useMemo) ==================== + describe('Text Display (useMemo)', () => { + it('should display "noInstalled" text when plugin list is empty', async () => { + // Arrange + setMockPluginList({ plugins: [] }) + + // Act + render() + await flushEffects() + + // Assert + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() + }) + + it('should display "notFound" text when filters are active with plugins', async () => { + // Arrange + setMockPluginList({ plugins: [{ id: 'plugin-1' }] }) + + // Test categories filter + setMockFilters({ categories: ['model'] }) + const { rerender } = render() + await flushEffects() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() + + // Test tags filter + setMockFilters({ categories: [], tags: ['tag1'] }) + rerender() + await flushEffects() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() + + // Test searchQuery filter + setMockFilters({ tags: [], searchQuery: 'test query' }) + rerender() + await flushEffects() + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() + }) + + it('should prioritize "noInstalled" over "notFound" when no plugins exist', async () => { + // Arrange + setMockFilters({ categories: ['model'], searchQuery: 'test' }) + setMockPluginList({ plugins: [] }) + + // Act + render() + await flushEffects() + + // Assert + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() + }) + }) + + // ==================== Install Methods Tests (useEffect) ==================== + describe('Install Methods (useEffect)', () => { + it('should render all three install methods when marketplace enabled and not restricted', async () => { + // Arrange + setMockSystemFeatures({ + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + }) + + // Act + render() + await flushEffects() + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(3) + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.getByText('plugin.source.github')).toBeInTheDocument() + expect(screen.getByText('plugin.source.local')).toBeInTheDocument() + + // Verify button order + const buttonTexts = buttons.map(btn => btn.textContent) + expect(buttonTexts[0]).toContain('plugin.source.marketplace') + expect(buttonTexts[1]).toContain('plugin.source.github') + expect(buttonTexts[2]).toContain('plugin.source.local') + }) + + it('should render only marketplace method when restricted to marketplace only', async () => { + // Arrange + setMockSystemFeatures({ + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: true, + }, + }) + + // Act + render() + await flushEffects() + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(1) + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.queryByText('plugin.source.github')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.source.local')).not.toBeInTheDocument() + }) + + it('should render github and local methods when marketplace is disabled', async () => { + // Arrange + setMockSystemFeatures({ + enable_marketplace: false, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + }) + + // Act + render() + await flushEffects() + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + expect(screen.queryByText('plugin.source.marketplace')).not.toBeInTheDocument() + expect(screen.getByText('plugin.source.github')).toBeInTheDocument() + expect(screen.getByText('plugin.source.local')).toBeInTheDocument() + }) + + it('should render no methods when marketplace disabled and restricted', async () => { + // Arrange + setMockSystemFeatures({ + enable_marketplace: false, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: true, + }, + }) + + // Act + render() + await flushEffects() + + // Assert + const buttons = screen.queryAllByRole('button') + expect(buttons).toHaveLength(0) + }) + }) + + // ==================== User Interactions Tests ==================== + describe('User Interactions', () => { + it('should call setActiveTab with "discover" when marketplace button is clicked', async () => { + // Arrange + render() + await flushEffects() + + // Act + fireEvent.click(screen.getByText('plugin.source.marketplace')) + + // Assert + expect(mockSetActiveTab).toHaveBeenCalledWith('discover') + }) + + it('should open and close GitHub modal correctly', async () => { + // Arrange + render() + await flushEffects() + + // Assert - initially no modal + expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() + + // Act - open modal + fireEvent.click(screen.getByText('plugin.source.github')) + + // Assert - modal is open + expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + + // Act - close modal + fireEvent.click(screen.getByTestId('github-modal-close')) + + // Assert - modal is closed + expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() + }) + + it('should trigger file input click when local button is clicked', async () => { + // Arrange + render() + await flushEffects() + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + const clickSpy = vi.spyOn(fileInput, 'click') + + // Act + fireEvent.click(screen.getByText('plugin.source.local')) + + // Assert + expect(clickSpy).toHaveBeenCalled() + }) + + it('should open and close local modal when file is selected', async () => { + // Arrange + render() + await flushEffects() + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + const mockFile = createMockFile('test-plugin.difypkg') + + // Assert - initially no modal + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + + // Act - select file + Object.defineProperty(fileInput, 'files', { value: [mockFile], writable: true }) + fireEvent.change(fileInput) + + // Assert - modal is open with correct file + expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument() + expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-plugin.difypkg') + + // Act - close modal + fireEvent.click(screen.getByTestId('local-modal-close')) + + // Assert - modal is closed + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + }) + + it('should not open local modal when no file is selected', async () => { + // Arrange + render() + await flushEffects() + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + + // Act - trigger change with empty files + Object.defineProperty(fileInput, 'files', { value: [], writable: true }) + fireEvent.change(fileInput) + + // Assert + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + }) + }) + + // ==================== State Management Tests ==================== + describe('State Management', () => { + it('should maintain modal state correctly and allow reopening', async () => { + // Arrange + render() + await flushEffects() + + // Act - Open, close, and reopen GitHub modal + fireEvent.click(screen.getByText('plugin.source.github')) + expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('github-modal-close')) + expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('plugin.source.github')) + expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + }) + + it('should update selectedFile state when file is selected', async () => { + // Arrange + render() + await flushEffects() + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + + // Act - select .difypkg file + Object.defineProperty(fileInput, 'files', { value: [createMockFile('my-plugin.difypkg')], writable: true }) + fireEvent.change(fileInput) + expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'my-plugin.difypkg') + + // Close and select .difybndl file + fireEvent.click(screen.getByTestId('local-modal-close')) + Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-bundle.difybndl')], writable: true }) + fireEvent.change(fileInput) + expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-bundle.difybndl') + }) + }) + + // ==================== Side Effects Tests ==================== + describe('Side Effects', () => { + it('should update installMethods when system features change', async () => { + // Arrange - Start with marketplace enabled + setMockSystemFeatures({ + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + }) + + const { rerender } = render() + await flushEffects() + + // Assert initial state - 3 methods + expect(screen.getAllByRole('button')).toHaveLength(3) + + // Act - Restrict to marketplace only + setMockSystemFeatures({ + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: true, + }, + }) + rerender() + await flushEffects() + + // Assert - Only marketplace button + expect(screen.getAllByRole('button')).toHaveLength(1) + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + }) + + it('should update text when pluginList or filters change', async () => { + // Arrange + setMockPluginList({ plugins: [] }) + const { rerender } = render() + await flushEffects() + + // Assert initial state + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() + + // Act - Update to have plugins with filters + setMockFilters({ categories: ['tool'] }) + setMockPluginList({ plugins: [{ id: 'plugin-1' }] }) + rerender() + await flushEffects() + + // Assert + expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() + }) + }) + + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it('should handle undefined/null plugin data gracefully', () => { + // Test undefined plugin list + setMockPluginList(undefined) + expect(() => render()).not.toThrow() + + // Test null plugins array + mockUseInstalledPluginList.mockReturnValue({ data: { plugins: null as any } }) + expect(() => render()).not.toThrow() + }) + + it('should handle file input edge cases', async () => { + // Arrange + render() + await flushEffects() + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + + // Test undefined files + Object.defineProperty(fileInput, 'files', { value: undefined, writable: true }) + fireEvent.change(fileInput) + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + }) + }) + + // ==================== React.memo Tests ==================== + describe('React.memo Behavior', () => { + it('should be wrapped with React.memo and have displayName', () => { + // Assert + expect(Empty).toBeDefined() + expect((Empty as any).$$typeof?.toString()).toContain('Symbol') + expect((Empty as any).displayName || (Empty as any).type?.displayName).toBeDefined() + }) + }) + + // ==================== Modal Callbacks Tests ==================== + describe('Modal Callbacks', () => { + it('should handle modal onSuccess callbacks (noop)', async () => { + // Arrange + render() + await flushEffects() + + // Test GitHub modal onSuccess + fireEvent.click(screen.getByText('plugin.source.github')) + fireEvent.click(screen.getByTestId('github-modal-success')) + expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + + // Close GitHub modal and test Local modal onSuccess + fireEvent.click(screen.getByTestId('github-modal-close')) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-plugin.difypkg')], writable: true }) + fireEvent.change(fileInput) + + fireEvent.click(screen.getByTestId('local-modal-success')) + expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument() + }) + }) + + // ==================== Conditional Modal Rendering ==================== + describe('Conditional Modal Rendering', () => { + it('should only render one modal at a time and require file for local modal', async () => { + // Arrange + render() + await flushEffects() + + // Assert - no modals initially + expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + + // Open GitHub modal - only GitHub modal visible + fireEvent.click(screen.getByText('plugin.source.github')) + expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + + // Click local button - triggers file input, no modal yet (no file selected) + fireEvent.click(screen.getByText('plugin.source.local')) + // GitHub modal should still be visible, local modal requires file selection + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx new file mode 100644 index 0000000000..58474b4723 --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx @@ -0,0 +1,1175 @@ +import type { Category, Tag } from './constant' +import type { FilterState } from './index' +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ==================== Imports (after mocks) ==================== + +import CategoriesFilter from './category-filter' +// Import real components +import FilterManagement from './index' +import SearchBox from './search-box' +import { useStore } from './store' +import TagFilter from './tag-filter' + +// ==================== Mock Setup ==================== + +// Mock initial filters from context +let mockInitFilters: FilterState = { + categories: [], + tags: [], + searchQuery: '', +} + +vi.mock('../context', () => ({ + usePluginPageContext: (selector: (v: { filters: FilterState }) => FilterState) => + selector({ filters: mockInitFilters }), +})) + +// Mock categories data +const mockCategories = [ + { name: 'model', label: 'Models' }, + { name: 'tool', label: 'Tools' }, + { name: 'extension', label: 'Extensions' }, + { name: 'agent', label: 'Agents' }, +] + +const mockCategoriesMap: Record = { + model: { name: 'model', label: 'Models' }, + tool: { name: 'tool', label: 'Tools' }, + extension: { name: 'extension', label: 'Extensions' }, + agent: { name: 'agent', label: 'Agents' }, +} + +// Mock tags data +const mockTags = [ + { name: 'agent', label: 'Agent' }, + { name: 'rag', label: 'RAG' }, + { name: 'search', label: 'Search' }, + { name: 'image', label: 'Image' }, +] + +const mockTagsMap: Record = { + agent: { name: 'agent', label: 'Agent' }, + rag: { name: 'rag', label: 'RAG' }, + search: { name: 'search', label: 'Search' }, + image: { name: 'image', label: 'Image' }, +} + +vi.mock('../../hooks', () => ({ + useCategories: () => ({ + categories: mockCategories, + categoriesMap: mockCategoriesMap, + }), + useTags: () => ({ + tags: mockTags, + tagsMap: mockTagsMap, + getTagLabel: (name: string) => mockTagsMap[name]?.label || name, + }), +})) + +// Track portal open state for testing +let mockPortalOpenState = false + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => { + mockPortalOpenState = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( +
{children}
+ ), + PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => { + if (!mockPortalOpenState) + return null + return
{children}
+ }, +})) + +// ==================== Test Utilities ==================== + +const createFilterState = (overrides: Partial = {}): FilterState => ({ + categories: [], + tags: [], + searchQuery: '', + ...overrides, +}) + +const renderFilterManagement = (onFilterChange = vi.fn()) => { + const result = render() + return { ...result, onFilterChange } +} + +// ==================== constant.ts Tests ==================== +describe('constant.ts - Type Definitions', () => { + it('should define Tag type correctly', () => { + // Arrange + const tag: Tag = { + id: 'test-id', + name: 'test-tag', + type: 'custom', + binding_count: 5, + } + + // Assert + expect(tag.id).toBe('test-id') + expect(tag.name).toBe('test-tag') + expect(tag.type).toBe('custom') + expect(tag.binding_count).toBe(5) + }) + + it('should define Category type correctly', () => { + // Arrange + const category: Category = { + name: 'model', + binding_count: 10, + } + + // Assert + expect(category.name).toBe('model') + expect(category.binding_count).toBe(10) + }) + + it('should enforce Category name as specific union type', () => { + // Arrange - Valid category names + const validNames: Array = ['model', 'tool', 'extension', 'bundle'] + + // Assert + validNames.forEach((name) => { + const category: Category = { name, binding_count: 0 } + expect(['model', 'tool', 'extension', 'bundle']).toContain(category.name) + }) + }) +}) + +// ==================== store.ts Tests ==================== +describe('store.ts - Zustand Store', () => { + beforeEach(() => { + // Reset store to initial state + const { setState } = useStore + setState({ + tagList: [], + categoryList: [], + showTagManagementModal: false, + showCategoryManagementModal: false, + }) + }) + + describe('Initial State', () => { + it('should have empty tagList initially', () => { + const { result } = renderHook(() => useStore(state => state.tagList)) + expect(result.current).toEqual([]) + }) + + it('should have empty categoryList initially', () => { + const { result } = renderHook(() => useStore(state => state.categoryList)) + expect(result.current).toEqual([]) + }) + + it('should have showTagManagementModal false initially', () => { + const { result } = renderHook(() => useStore(state => state.showTagManagementModal)) + expect(result.current).toBe(false) + }) + + it('should have showCategoryManagementModal false initially', () => { + const { result } = renderHook(() => useStore(state => state.showCategoryManagementModal)) + expect(result.current).toBe(false) + }) + }) + + describe('setTagList', () => { + it('should update tagList', () => { + // Arrange + const mockTagList: Tag[] = [ + { id: '1', name: 'tag1', type: 'custom', binding_count: 1 }, + { id: '2', name: 'tag2', type: 'custom', binding_count: 2 }, + ] + + // Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setTagList(mockTagList) + }) + + // Assert + expect(result.current.tagList).toEqual(mockTagList) + }) + + it('should handle undefined tagList', () => { + // Arrange & Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setTagList(undefined) + }) + + // Assert + expect(result.current.tagList).toBeUndefined() + }) + + it('should handle empty tagList', () => { + // Arrange + const { result } = renderHook(() => useStore()) + + // First set some tags + act(() => { + result.current.setTagList([{ id: '1', name: 'tag1', type: 'custom', binding_count: 1 }]) + }) + + // Act - Clear the list + act(() => { + result.current.setTagList([]) + }) + + // Assert + expect(result.current.tagList).toEqual([]) + }) + }) + + describe('setCategoryList', () => { + it('should update categoryList', () => { + // Arrange + const mockCategoryList: Category[] = [ + { name: 'model', binding_count: 5 }, + { name: 'tool', binding_count: 10 }, + ] + + // Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setCategoryList(mockCategoryList) + }) + + // Assert + expect(result.current.categoryList).toEqual(mockCategoryList) + }) + + it('should handle undefined categoryList', () => { + // Arrange & Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setCategoryList(undefined) + }) + + // Assert + expect(result.current.categoryList).toBeUndefined() + }) + }) + + describe('setShowTagManagementModal', () => { + it('should set showTagManagementModal to true', () => { + // Arrange & Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setShowTagManagementModal(true) + }) + + // Assert + expect(result.current.showTagManagementModal).toBe(true) + }) + + it('should set showTagManagementModal to false', () => { + // Arrange + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setShowTagManagementModal(true) + }) + + // Act + act(() => { + result.current.setShowTagManagementModal(false) + }) + + // Assert + expect(result.current.showTagManagementModal).toBe(false) + }) + }) + + describe('setShowCategoryManagementModal', () => { + it('should set showCategoryManagementModal to true', () => { + // Arrange & Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setShowCategoryManagementModal(true) + }) + + // Assert + expect(result.current.showCategoryManagementModal).toBe(true) + }) + + it('should set showCategoryManagementModal to false', () => { + // Arrange + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setShowCategoryManagementModal(true) + }) + + // Act + act(() => { + result.current.setShowCategoryManagementModal(false) + }) + + // Assert + expect(result.current.showCategoryManagementModal).toBe(false) + }) + }) + + describe('Store Isolation', () => { + it('should maintain separate state for each property', () => { + // Arrange + const mockTagList: Tag[] = [{ id: '1', name: 'tag1', type: 'custom', binding_count: 1 }] + const mockCategoryList: Category[] = [{ name: 'model', binding_count: 5 }] + + // Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setTagList(mockTagList) + result.current.setCategoryList(mockCategoryList) + result.current.setShowTagManagementModal(true) + result.current.setShowCategoryManagementModal(false) + }) + + // Assert - All states are independent + expect(result.current.tagList).toEqual(mockTagList) + expect(result.current.categoryList).toEqual(mockCategoryList) + expect(result.current.showTagManagementModal).toBe(true) + expect(result.current.showCategoryManagementModal).toBe(false) + }) + }) +}) + +// ==================== search-box.tsx Tests ==================== +describe('SearchBox Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render input with correct placeholder', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByPlaceholderText('plugin.search')).toBeInTheDocument() + }) + + it('should render with provided searchQuery value', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByDisplayValue('test query')).toBeInTheDocument() + }) + + it('should render search icon', () => { + // Arrange & Act + const { container } = render() + + // Assert - Input should have showLeftIcon which renders search icon + const wrapper = container.querySelector('.w-\\[200px\\]') + expect(wrapper).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onChange when input value changes', () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: 'new search' }, + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith('new search') + }) + + it('should call onChange with empty string when cleared', () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByDisplayValue('existing'), { + target: { value: '' }, + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith('') + }) + + it('should handle rapid typing', () => { + // Arrange + const handleChange = vi.fn() + render() + const input = screen.getByPlaceholderText('plugin.search') + + // Act + fireEvent.change(input, { target: { value: 'a' } }) + fireEvent.change(input, { target: { value: 'ab' } }) + fireEvent.change(input, { target: { value: 'abc' } }) + + // Assert + expect(handleChange).toHaveBeenCalledTimes(3) + expect(handleChange).toHaveBeenLastCalledWith('abc') + }) + }) + + describe('Edge Cases', () => { + it('should handle special characters', () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: '!@#$%^&*()' }, + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith('!@#$%^&*()') + }) + + it('should handle unicode characters', () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: '中文搜索 🔍' }, + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith('中文搜索 🔍') + }) + + it('should handle very long input', () => { + // Arrange + const handleChange = vi.fn() + const longText = 'a'.repeat(500) + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: longText }, + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith(longText) + }) + }) +}) + +// ==================== category-filter.tsx Tests ==================== +describe('CategoriesFilter Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + }) + + describe('Rendering', () => { + it('should render with "All Categories" text when no selection', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('plugin.allCategories')).toBeInTheDocument() + }) + + it('should render dropdown arrow when no selection', () => { + // Arrange & Act + const { container } = render() + + // Assert - Arrow icon should be visible + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).toBeInTheDocument() + }) + + it('should render selected category labels', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Models')).toBeInTheDocument() + }) + + it('should show clear button when categories are selected', () => { + // Arrange & Act + const { container } = render() + + // Assert - Close icon should be visible + const closeIcon = container.querySelector('[class*="cursor-pointer"]') + expect(closeIcon).toBeInTheDocument() + }) + + it('should show count badge for more than 2 selections', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('+1')).toBeInTheDocument() + }) + }) + + describe('Dropdown Behavior', () => { + it('should open dropdown on trigger click', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) + + it('should display category options in dropdown', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByText('Models')).toBeInTheDocument() + expect(screen.getByText('Tools')).toBeInTheDocument() + expect(screen.getByText('Extensions')).toBeInTheDocument() + expect(screen.getByText('Agents')).toBeInTheDocument() + }) + }) + + it('should have search input in dropdown', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByPlaceholderText('plugin.searchCategories')).toBeInTheDocument() + }) + }) + }) + + describe('Selection Behavior', () => { + it('should call onChange when category is selected', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act - Open dropdown and click category + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByText('Models')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Models')) + + // Assert + expect(handleChange).toHaveBeenCalledWith(['model']) + }) + + it('should deselect when clicking selected category', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + // Multiple "Models" texts exist - one in trigger, one in dropdown + const allModels = screen.getAllByText('Models') + expect(allModels.length).toBeGreaterThan(1) + }) + // Click the one in the dropdown (inside portal-content) + const portalContent = screen.getByTestId('portal-content') + const modelsInDropdown = portalContent.querySelector('.system-sm-medium')! + fireEvent.click(modelsInDropdown.parentElement!) + + // Assert + expect(handleChange).toHaveBeenCalledWith([]) + }) + + it('should add to selection when clicking unselected category', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByText('Tools')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Tools')) + + // Assert + expect(handleChange).toHaveBeenCalledWith(['model', 'tool']) + }) + + it('should clear all selections when clear button is clicked', () => { + // Arrange + const handleChange = vi.fn() + const { container } = render() + + // Act - Find and click the close icon + const closeIcon = container.querySelector('.text-text-quaternary') + expect(closeIcon).toBeInTheDocument() + fireEvent.click(closeIcon!) + + // Assert + expect(handleChange).toHaveBeenCalledWith([]) + }) + }) + + describe('Search Functionality', () => { + it('should filter categories based on search text', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByPlaceholderText('plugin.searchCategories')).toBeInTheDocument() + }) + fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), { + target: { value: 'mod' }, + }) + + // Assert + expect(screen.getByText('Models')).toBeInTheDocument() + expect(screen.queryByText('Extensions')).not.toBeInTheDocument() + }) + + it('should be case insensitive', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByPlaceholderText('plugin.searchCategories')).toBeInTheDocument() + }) + fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), { + target: { value: 'MOD' }, + }) + + // Assert + expect(screen.getByText('Models')).toBeInTheDocument() + }) + }) + + describe('Checkbox State', () => { + it('should show checked checkbox for selected categories', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert - Check icon appears for checked state + await waitFor(() => { + const checkIcons = screen.getAllByTestId(/check-icon/) + expect(checkIcons.length).toBeGreaterThan(0) + }) + }) + + it('should show unchecked checkbox for unselected categories', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert - No check icon for unchecked state + await waitFor(() => { + const checkIcons = screen.queryAllByTestId(/check-icon/) + expect(checkIcons.length).toBe(0) + }) + }) + }) +}) + +// ==================== tag-filter.tsx Tests ==================== +describe('TagFilter Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + }) + + describe('Rendering', () => { + it('should render with "All Tags" text when no selection', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument() + }) + + it('should render selected tag labels', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + + it('should show count badge for more than 2 selections', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('should show clear button when tags are selected', () => { + // Arrange & Act + const { container } = render() + + // Assert + const closeIcon = container.querySelector('.text-text-quaternary') + expect(closeIcon).toBeInTheDocument() + }) + }) + + describe('Dropdown Behavior', () => { + it('should open dropdown on trigger click', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) + + it('should display tag options in dropdown', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + expect(screen.getByText('RAG')).toBeInTheDocument() + expect(screen.getByText('Search')).toBeInTheDocument() + expect(screen.getByText('Image')).toBeInTheDocument() + }) + }) + }) + + describe('Selection Behavior', () => { + it('should call onChange when tag is selected', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Agent')) + + // Assert + expect(handleChange).toHaveBeenCalledWith(['agent']) + }) + + it('should deselect when clicking selected tag', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + // Find the Agent option in dropdown + const agentOptions = screen.getAllByText('Agent') + fireEvent.click(agentOptions[agentOptions.length - 1]) + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith([]) + }) + + it('should add to selection when clicking unselected tag', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByText('RAG')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('RAG')) + + // Assert + expect(handleChange).toHaveBeenCalledWith(['agent', 'rag']) + }) + + it('should clear all selections when clear button is clicked', () => { + // Arrange + const handleChange = vi.fn() + const { container } = render() + + // Act + const closeIcon = container.querySelector('.text-text-quaternary') + fireEvent.click(closeIcon!) + + // Assert + expect(handleChange).toHaveBeenCalledWith([]) + }) + }) + + describe('Search Functionality', () => { + it('should filter tags based on search text', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByPlaceholderText('pluginTags.searchTags')).toBeInTheDocument() + }) + fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { + target: { value: 'rag' }, + }) + + // Assert + expect(screen.getByText('RAG')).toBeInTheDocument() + expect(screen.queryByText('Image')).not.toBeInTheDocument() + }) + }) +}) + +// ==================== index.tsx (FilterManagement) Tests ==================== +describe('FilterManagement Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockInitFilters = createFilterState() + mockPortalOpenState = false + }) + + describe('Rendering', () => { + it('should render all filter components', () => { + // Arrange & Act + renderFilterManagement() + + // Assert - All three filters should be present + expect(screen.getByText('plugin.allCategories')).toBeInTheDocument() + expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument() + expect(screen.getByPlaceholderText('plugin.search')).toBeInTheDocument() + }) + + it('should render with correct container classes', () => { + // Arrange & Act + const { container } = renderFilterManagement() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'items-center', 'gap-2', 'self-stretch') + }) + }) + + describe('Initial State from Context', () => { + it('should initialize with empty filters', () => { + // Arrange + mockInitFilters = createFilterState() + + // Act + renderFilterManagement() + + // Assert + expect(screen.getByText('plugin.allCategories')).toBeInTheDocument() + expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument() + expect(screen.getByPlaceholderText('plugin.search')).toHaveValue('') + }) + + it('should initialize with pre-selected categories', () => { + // Arrange + mockInitFilters = createFilterState({ categories: ['model'] }) + + // Act + renderFilterManagement() + + // Assert + expect(screen.getByText('Models')).toBeInTheDocument() + }) + + it('should initialize with pre-selected tags', () => { + // Arrange + mockInitFilters = createFilterState({ tags: ['agent'] }) + + // Act + renderFilterManagement() + + // Assert + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + + it('should initialize with search query', () => { + // Arrange + mockInitFilters = createFilterState({ searchQuery: 'initial search' }) + + // Act + renderFilterManagement() + + // Assert + expect(screen.getByDisplayValue('initial search')).toBeInTheDocument() + }) + }) + + describe('Filter Interactions', () => { + it('should call onFilterChange when category is selected', async () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act - Open categories dropdown and select + const triggers = screen.getAllByTestId('portal-trigger') + fireEvent.click(triggers[0]) // Categories filter trigger + + await waitFor(() => { + expect(screen.getByText('Models')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Models')) + + // Assert + expect(onFilterChange).toHaveBeenCalledWith({ + categories: ['model'], + tags: [], + searchQuery: '', + }) + }) + + it('should call onFilterChange when tag is selected', async () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act - Open tags dropdown and select + const triggers = screen.getAllByTestId('portal-trigger') + fireEvent.click(triggers[1]) // Tags filter trigger + + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Agent')) + + // Assert + expect(onFilterChange).toHaveBeenCalledWith({ + categories: [], + tags: ['agent'], + searchQuery: '', + }) + }) + + it('should call onFilterChange when search query changes', () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: 'test query' }, + }) + + // Assert + expect(onFilterChange).toHaveBeenCalledWith({ + categories: [], + tags: [], + searchQuery: 'test query', + }) + }) + }) + + describe('State Management', () => { + it('should accumulate filter changes', async () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act 1 - Select a category + const triggers = screen.getAllByTestId('portal-trigger') + fireEvent.click(triggers[0]) + await waitFor(() => { + expect(screen.getByText('Models')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Models')) + + expect(onFilterChange).toHaveBeenLastCalledWith({ + categories: ['model'], + tags: [], + searchQuery: '', + }) + + // Close dropdown by clicking trigger again + fireEvent.click(triggers[0]) + + // Act 2 - Select a tag (state should include previous category) + fireEvent.click(triggers[1]) + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Agent')) + + // Assert - Both category and tag should be in the state + expect(onFilterChange).toHaveBeenLastCalledWith({ + categories: ['model'], + tags: ['agent'], + searchQuery: '', + }) + }) + + it('should preserve other filters when updating one', () => { + // Arrange + mockInitFilters = createFilterState({ + categories: ['model'], + tags: ['agent'], + }) + const onFilterChange = vi.fn() + render() + + // Act - Change only search query + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: 'new search' }, + }) + + // Assert - Other filters should be preserved + expect(onFilterChange).toHaveBeenCalledWith({ + categories: ['model'], + tags: ['agent'], + searchQuery: 'new search', + }) + }) + }) + + describe('Integration Tests', () => { + it('should handle complete filter workflow', async () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act 1 - Select categories + const triggers = screen.getAllByTestId('portal-trigger') + fireEvent.click(triggers[0]) + await waitFor(() => { + expect(screen.getByText('Models')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Models')) + fireEvent.click(triggers[0]) // Close + + // Act 2 - Select tags + fireEvent.click(triggers[1]) + await waitFor(() => { + expect(screen.getByText('RAG')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('RAG')) + fireEvent.click(triggers[1]) // Close + + // Act 3 - Enter search + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: 'gpt' }, + }) + + // Assert - Final state should include all filters + expect(onFilterChange).toHaveBeenLastCalledWith({ + categories: ['model'], + tags: ['rag'], + searchQuery: 'gpt', + }) + }) + + it('should handle filter clearing', async () => { + // Arrange + mockInitFilters = createFilterState({ + categories: ['model'], + tags: ['agent'], + searchQuery: 'test', + }) + const onFilterChange = vi.fn() + const { container } = render() + + // Act - Clear search + fireEvent.change(screen.getByDisplayValue('test'), { + target: { value: '' }, + }) + + // Assert + expect(onFilterChange).toHaveBeenLastCalledWith({ + categories: ['model'], + tags: ['agent'], + searchQuery: '', + }) + + // Act - Clear categories (click clear button) + const closeIcons = container.querySelectorAll('.text-text-quaternary') + fireEvent.click(closeIcons[0]) // First close icon is for categories + + // Assert + expect(onFilterChange).toHaveBeenLastCalledWith({ + categories: [], + tags: ['agent'], + searchQuery: '', + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty initial state', () => { + // Arrange + mockInitFilters = createFilterState() + const onFilterChange = vi.fn() + + // Act + render() + + // Assert - Should render without errors + expect(screen.getByText('plugin.allCategories')).toBeInTheDocument() + }) + + it('should handle multiple rapid filter changes', () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act - Rapid search input changes + const searchInput = screen.getByPlaceholderText('plugin.search') + fireEvent.change(searchInput, { target: { value: 'a' } }) + fireEvent.change(searchInput, { target: { value: 'ab' } }) + fireEvent.change(searchInput, { target: { value: 'abc' } }) + + // Assert + expect(onFilterChange).toHaveBeenCalledTimes(3) + expect(onFilterChange).toHaveBeenLastCalledWith( + expect.objectContaining({ searchQuery: 'abc' }), + ) + }) + + it('should handle special characters in search', () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: '!@#$%^&*()' }, + }) + + // Assert + expect(onFilterChange).toHaveBeenCalledWith( + expect.objectContaining({ searchQuery: '!@#$%^&*()' }), + ) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/list/index.spec.tsx b/web/app/components/plugins/plugin-page/list/index.spec.tsx new file mode 100644 index 0000000000..7709585e8e --- /dev/null +++ b/web/app/components/plugins/plugin-page/list/index.spec.tsx @@ -0,0 +1,702 @@ +import type { PluginDeclaration, PluginDetail } from '../../types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, PluginSource } from '../../types' + +// ==================== Imports (after mocks) ==================== + +import PluginList from './index' + +// ==================== Mock Setup ==================== + +// Mock PluginItem component to avoid complex dependency chain +vi.mock('../../plugin-item', () => ({ + default: ({ plugin }: { plugin: PluginDetail }) => ( +
+ {plugin.name} +
+ ), +})) + +// ==================== Test Utilities ==================== + +/** + * Factory function to create a PluginDeclaration with defaults + */ +const createPluginDeclaration = (overrides: Partial = {}): PluginDeclaration => ({ + plugin_unique_identifier: 'test-plugin-id', + version: '1.0.0', + author: 'test-author', + icon: 'test-icon.png', + icon_dark: 'test-icon-dark.png', + name: 'test-plugin', + category: PluginCategoryEnum.tool, + label: { en_US: 'Test Plugin' } as any, + description: { en_US: 'Test plugin description' } as any, + created_at: '2024-01-01', + resource: null, + plugins: null, + verified: false, + endpoint: {} as any, + model: null, + tags: [], + agent_strategy: null, + meta: { + version: '1.0.0', + minimum_dify_version: '0.5.0', + }, + trigger: {} as any, + ...overrides, +}) + +/** + * Factory function to create a PluginDetail with defaults + */ +const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({ + id: 'plugin-1', + created_at: '2024-01-01', + updated_at: '2024-01-01', + name: 'test-plugin', + plugin_id: 'plugin-1', + plugin_unique_identifier: 'test-author/test-plugin@1.0.0', + declaration: createPluginDeclaration(), + 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-author/test-plugin@1.0.0', + source: PluginSource.marketplace, + meta: { + repo: 'test-author/test-plugin', + version: '1.0.0', + package: 'test-plugin.difypkg', + }, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +/** + * Factory function to create a list of plugins + */ +const createPluginList = (count: number, baseOverrides: Partial = {}): PluginDetail[] => { + return Array.from({ length: count }, (_, index) => createPluginDetail({ + id: `plugin-${index + 1}`, + plugin_id: `plugin-${index + 1}`, + name: `plugin-${index + 1}`, + plugin_unique_identifier: `test-author/plugin-${index + 1}@1.0.0`, + ...baseOverrides, + })) +} + +// ==================== Tests ==================== + +describe('PluginList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ==================== Rendering Tests ==================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const pluginList: PluginDetail[] = [] + + // Act + const { container } = render() + + // Assert + expect(container).toBeInTheDocument() + }) + + it('should render container with correct structure', () => { + // Arrange + const pluginList: PluginDetail[] = [] + + // Act + const { container } = render() + + // Assert + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('pb-3') + + const gridDiv = outerDiv.firstChild as HTMLElement + expect(gridDiv).toHaveClass('grid', 'grid-cols-2', 'gap-3') + }) + + it('should render single plugin correctly', () => { + // Arrange + const pluginList = [createPluginDetail({ name: 'single-plugin' })] + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(1) + expect(pluginItems[0]).toHaveAttribute('data-plugin-name', 'single-plugin') + }) + + it('should render multiple plugins correctly', () => { + // Arrange + const pluginList = createPluginList(5) + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(5) + }) + + it('should render plugins in correct order', () => { + // Arrange + const pluginList = [ + createPluginDetail({ plugin_id: 'first', name: 'First Plugin' }), + createPluginDetail({ plugin_id: 'second', name: 'Second Plugin' }), + createPluginDetail({ plugin_id: 'third', name: 'Third Plugin' }), + ] + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems[0]).toHaveAttribute('data-plugin-id', 'first') + expect(pluginItems[1]).toHaveAttribute('data-plugin-id', 'second') + expect(pluginItems[2]).toHaveAttribute('data-plugin-id', 'third') + }) + + it('should pass plugin prop to each PluginItem', () => { + // Arrange + const pluginList = [ + createPluginDetail({ plugin_id: 'plugin-a', name: 'Plugin A' }), + createPluginDetail({ plugin_id: 'plugin-b', name: 'Plugin B' }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Plugin A')).toBeInTheDocument() + expect(screen.getByText('Plugin B')).toBeInTheDocument() + }) + }) + + // ==================== Props Testing ==================== + describe('Props', () => { + it('should accept empty pluginList array', () => { + // Arrange & Act + const { container } = render() + + // Assert + const gridDiv = container.querySelector('.grid') + expect(gridDiv).toBeEmptyDOMElement() + }) + + it('should handle pluginList with various categories', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + plugin_id: 'tool-plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + }), + createPluginDetail({ + plugin_id: 'model-plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }), + }), + createPluginDetail({ + plugin_id: 'extension-plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.extension }), + }), + ] + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(3) + }) + + it('should handle pluginList with various sources', () => { + // Arrange + const pluginList = [ + createPluginDetail({ plugin_id: 'marketplace-plugin', source: PluginSource.marketplace }), + createPluginDetail({ plugin_id: 'github-plugin', source: PluginSource.github }), + createPluginDetail({ plugin_id: 'local-plugin', source: PluginSource.local }), + createPluginDetail({ plugin_id: 'debugging-plugin', source: PluginSource.debugging }), + ] + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(4) + }) + }) + + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it('should handle empty array', () => { + // Arrange & Act + render() + + // Assert + expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument() + }) + + it('should handle large number of plugins', () => { + // Arrange + const pluginList = createPluginList(100) + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(100) + }) + + it('should handle plugins with duplicate plugin_ids (key warning scenario)', () => { + // Arrange - Testing that the component uses plugin_id as key + const pluginList = [ + createPluginDetail({ plugin_id: 'unique-1', name: 'Plugin 1' }), + createPluginDetail({ plugin_id: 'unique-2', name: 'Plugin 2' }), + ] + + // Act & Assert - Should render without issues + expect(() => render()).not.toThrow() + expect(screen.getAllByTestId('plugin-item')).toHaveLength(2) + }) + + it('should handle plugins with special characters in names', () => { + // Arrange + const pluginList = [ + createPluginDetail({ plugin_id: 'special-1', name: 'Plugin "special" & chars' }), + createPluginDetail({ plugin_id: 'special-2', name: '日本語プラグイン' }), + createPluginDetail({ plugin_id: 'special-3', name: 'Emoji Plugin 🔌' }), + ] + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(3) + }) + + it('should handle plugins with very long names', () => { + // Arrange + const longName = 'A'.repeat(500) + const pluginList = [createPluginDetail({ name: longName })] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + + it('should handle plugin with minimal data', () => { + // Arrange + const minimalPlugin = createPluginDetail({ + name: '', + plugin_id: 'minimal', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + + it('should handle plugins with undefined optional fields', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + plugin_id: 'no-meta', + meta: undefined, + }), + ] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + }) + + // ==================== Grid Layout Tests ==================== + describe('Grid Layout', () => { + it('should render with 2-column grid', () => { + // Arrange + const pluginList = createPluginList(4) + + // Act + const { container } = render() + + // Assert + const gridDiv = container.querySelector('.grid') + expect(gridDiv).toHaveClass('grid-cols-2') + }) + + it('should have proper gap between items', () => { + // Arrange + const pluginList = createPluginList(4) + + // Act + const { container } = render() + + // Assert + const gridDiv = container.querySelector('.grid') + expect(gridDiv).toHaveClass('gap-3') + }) + + it('should have bottom padding on container', () => { + // Arrange + const pluginList = createPluginList(2) + + // Act + const { container } = render() + + // Assert + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('pb-3') + }) + }) + + // ==================== Re-render Tests ==================== + describe('Re-render Behavior', () => { + it('should update when pluginList changes', () => { + // Arrange + const initialList = createPluginList(2) + const updatedList = createPluginList(4) + + // Act + const { rerender } = render() + expect(screen.getAllByTestId('plugin-item')).toHaveLength(2) + + rerender() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(4) + }) + + it('should handle pluginList update from non-empty to empty', () => { + // Arrange + const initialList = createPluginList(3) + const emptyList: PluginDetail[] = [] + + // Act + const { rerender } = render() + expect(screen.getAllByTestId('plugin-item')).toHaveLength(3) + + rerender() + + // Assert + expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument() + }) + + it('should handle pluginList update from empty to non-empty', () => { + // Arrange + const emptyList: PluginDetail[] = [] + const filledList = createPluginList(3) + + // Act + const { rerender } = render() + expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument() + + rerender() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(3) + }) + + it('should update individual plugin data on re-render', () => { + // Arrange + const initialList = [createPluginDetail({ plugin_id: 'plugin-1', name: 'Original Name' })] + const updatedList = [createPluginDetail({ plugin_id: 'plugin-1', name: 'Updated Name' })] + + // Act + const { rerender } = render() + expect(screen.getByText('Original Name')).toBeInTheDocument() + + rerender() + + // Assert + expect(screen.getByText('Updated Name')).toBeInTheDocument() + expect(screen.queryByText('Original Name')).not.toBeInTheDocument() + }) + }) + + // ==================== Key Prop Tests ==================== + describe('Key Prop Behavior', () => { + it('should use plugin_id as key for efficient re-renders', () => { + // Arrange - Create plugins with unique plugin_ids + const pluginList = [ + createPluginDetail({ plugin_id: 'stable-key-1', name: 'Plugin 1' }), + createPluginDetail({ plugin_id: 'stable-key-2', name: 'Plugin 2' }), + createPluginDetail({ plugin_id: 'stable-key-3', name: 'Plugin 3' }), + ] + + // Act + const { rerender } = render() + + // Reorder the list + const reorderedList = [pluginList[2], pluginList[0], pluginList[1]] + rerender() + + // Assert - All items should still be present + const items = screen.getAllByTestId('plugin-item') + expect(items).toHaveLength(3) + expect(items[0]).toHaveAttribute('data-plugin-id', 'stable-key-3') + expect(items[1]).toHaveAttribute('data-plugin-id', 'stable-key-1') + expect(items[2]).toHaveAttribute('data-plugin-id', 'stable-key-2') + }) + }) + + // ==================== Plugin Status Variations ==================== + describe('Plugin Status Variations', () => { + it('should render active plugins', () => { + // Arrange + const pluginList = [createPluginDetail({ status: 'active' })] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + + it('should render deleted/deprecated plugins', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + status: 'deleted', + deprecated_reason: 'No longer maintained', + }), + ] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + + it('should render mixed status plugins', () => { + // Arrange + const pluginList = [ + createPluginDetail({ plugin_id: 'active-plugin', status: 'active' }), + createPluginDetail({ + plugin_id: 'deprecated-plugin', + status: 'deleted', + deprecated_reason: 'Deprecated', + }), + ] + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(2) + }) + }) + + // ==================== Version Variations ==================== + describe('Version Variations', () => { + it('should render plugins with same version as latest', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + version: '1.0.0', + latest_version: '1.0.0', + }), + ] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + + it('should render plugins with outdated version', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + version: '1.0.0', + latest_version: '2.0.0', + }), + ] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + }) + + // ==================== Accessibility ==================== + describe('Accessibility', () => { + it('should render as a semantic container', () => { + // Arrange + const pluginList = createPluginList(2) + + // Act + const { container } = render() + + // Assert - The list is rendered as divs which is appropriate for a grid layout + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv.tagName).toBe('DIV') + }) + }) + + // ==================== Component Type ==================== + describe('Component Type', () => { + it('should be a functional component', () => { + // Assert + expect(typeof PluginList).toBe('function') + }) + + it('should accept pluginList as required prop', () => { + // Arrange & Act - TypeScript ensures this at compile time + // but we verify runtime behavior + const pluginList = createPluginList(1) + + // Assert + expect(() => render()).not.toThrow() + }) + }) + + // ==================== Mixed Content Tests ==================== + describe('Mixed Content', () => { + it('should render plugins from different sources together', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + plugin_id: 'marketplace-1', + name: 'Marketplace Plugin', + source: PluginSource.marketplace, + }), + createPluginDetail({ + plugin_id: 'github-1', + name: 'GitHub Plugin', + source: PluginSource.github, + }), + createPluginDetail({ + plugin_id: 'local-1', + name: 'Local Plugin', + source: PluginSource.local, + }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Marketplace Plugin')).toBeInTheDocument() + expect(screen.getByText('GitHub Plugin')).toBeInTheDocument() + expect(screen.getByText('Local Plugin')).toBeInTheDocument() + }) + + it('should render plugins of different categories together', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + plugin_id: 'tool-1', + name: 'Tool Plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + }), + createPluginDetail({ + plugin_id: 'model-1', + name: 'Model Plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }), + }), + createPluginDetail({ + plugin_id: 'agent-1', + name: 'Agent Plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.agent }), + }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Tool Plugin')).toBeInTheDocument() + expect(screen.getByText('Model Plugin')).toBeInTheDocument() + expect(screen.getByText('Agent Plugin')).toBeInTheDocument() + }) + }) + + // ==================== Boundary Tests ==================== + describe('Boundary Tests', () => { + it('should handle single item list', () => { + // Arrange + const pluginList = createPluginList(1) + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(1) + }) + + it('should handle two items (fills one row)', () => { + // Arrange + const pluginList = createPluginList(2) + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(2) + }) + + it('should handle three items (partial second row)', () => { + // Arrange + const pluginList = createPluginList(3) + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(3) + }) + + it('should handle odd number of items', () => { + // Arrange + const pluginList = createPluginList(7) + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(7) + }) + + it('should handle even number of items', () => { + // Arrange + const pluginList = createPluginList(8) + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(8) + }) + }) +})