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