diff --git a/web/app/components/plugins/plugin-page/empty/index.spec.tsx b/web/app/components/plugins/plugin-page/empty/index.spec.tsx index bfdf857662..0084fc6c3d 100644 --- a/web/app/components/plugins/plugin-page/empty/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/empty/index.spec.tsx @@ -1,273 +1,225 @@ import type { FilterState } from '../filter-management' import type { SystemFeatures } from '@/types/feature' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { InstallationScope } 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 -// ============================================================================ +// ==================== Mock Setup ==================== -// Create a stable t function reference to avoid infinite re-renders -// The component's useEffect depends on t, so it must be stable -const stableT = (key: string) => key +// 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, + } +}) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: stableT, - i18n: { - language: 'en', - changeLanguage: vi.fn(), - }, - }), +// 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 -const mockUseInstalledPluginList = vi.fn() vi.mock('@/service/use-plugins', () => ({ useInstalledPluginList: () => mockUseInstalledPluginList(), })) -// Mock system features from global public context -let mockSystemFeatures: Partial = { - enable_marketplace: true, - plugin_installation_permission: { - plugin_installation_scope: InstallationScope.ALL, - restrict_to_marketplace_only: false, - }, -} - -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: { systemFeatures: Partial }) => unknown) => - selector({ systemFeatures: mockSystemFeatures }), -})) - -// Mock plugin page context -let mockFilters: FilterState = { - categories: [], - tags: [], - searchQuery: '', -} -const mockSetActiveTab = vi.fn() - -vi.mock('../context', () => ({ - usePluginPageContext: (selector: (value: { filters: FilterState, setActiveTab: (tab: string) => void }) => unknown) => - selector({ filters: mockFilters, setActiveTab: mockSetActiveTab }), -})) - -// Mock install components +// Mock InstallFromGitHub component vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({ - default: ({ onClose, onSuccess }: { onClose: () => void, onSuccess: () => void }) => ( -
- - + default: ({ onClose }: { onSuccess: () => void, onClose: () => void }) => ( +
+ +
), })) +// Mock InstallFromLocalPackage component vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({ - default: ({ file, onClose, onSuccess }: { file: File, onClose: () => void, onSuccess: () => void }) => ( -
- - + default: ({ file, onClose }: { file: File, onSuccess: () => void, onClose: () => void }) => ( +
+ +
), })) // Mock Line component vi.mock('../../marketplace/empty/line', () => ({ - default: ({ className }: { className?: string }) => ( -
- ), + default: ({ className }: { className?: string }) =>
, })) -// ============================================================================ -// Test Data Factories -// ============================================================================ +// ==================== Test Utilities ==================== -const createDefaultSystemFeatures = (overrides: Partial = {}): Partial => ({ - enable_marketplace: true, - plugin_installation_permission: { - plugin_installation_scope: InstallationScope.ALL, - restrict_to_marketplace_only: false, - }, - ...overrides, -}) +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 createFilterState = (overrides: Partial = {}): FilterState => ({ - categories: [], - tags: [], - searchQuery: '', - ...overrides, -}) +const setMockFilters = (filters: Partial) => { + mockState.filters = { ...mockState.filters, ...filters } +} -const createPluginListResponse = (plugins: unknown[] = []) => ({ - data: { - plugins, - total: plugins.length, - }, -}) +const setMockSystemFeatures = (features: Partial) => { + mockState.systemFeatures = { ...mockState.systemFeatures, ...features } +} -// ============================================================================ -// Rendering Tests -// ============================================================================ -describe('Empty', () => { +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() - mockSystemFeatures = createDefaultSystemFeatures() - mockFilters = createFilterState() - mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([])) + resetMockState() }) + // ==================== Rendering Tests ==================== describe('Rendering', () => { - it('should render without crashing', () => { + 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 render skeleton grid background', () => { - // Arrange & Act - const { container } = render() + it('should display "notFound" text when filters are active with plugins', async () => { + // Arrange + setMockPluginList({ plugins: [{ id: 'plugin-1' }] }) - // Assert - const skeletonCards = container.querySelectorAll('.h-24.rounded-xl.bg-components-card-bg') - expect(skeletonCards.length).toBe(20) + // 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 render Group icon', () => { - // Arrange & Act - const { container } = render() + it('should prioritize "noInstalled" over "notFound" when no plugins exist', async () => { + // Arrange + setMockFilters({ categories: ['model'], searchQuery: 'test' }) + setMockPluginList({ plugins: [] }) - // Assert - const iconContainer = container.querySelector('.size-14') - expect(iconContainer).toBeInTheDocument() - }) - - it('should render decorative lines', () => { - // Arrange & Act + // Act render() + await flushEffects() // Assert - const lines = screen.getAllByTestId('line') - expect(lines.length).toBe(4) - }) - - it('should be wrapped with React.memo', () => { - // Assert - React.memo components have $$typeof Symbol(react.memo) - expect(Empty.$$typeof?.toString()).toBe('Symbol(react.memo)') - }) - - it('should have displayName set on inner component', () => { - // Assert - displayName is set on the inner component (type property of memo wrapper) - const innerComponent = (Empty as unknown as { type: { displayName?: string } }).type - expect(innerComponent?.displayName).toBe('Empty') + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() }) }) - // ============================================================================ - // State Management Tests - // ============================================================================ - describe('State Management', () => { - it('should initialize with no selected action', () => { - // Arrange & Act - render() - - // Assert - expect(screen.queryByTestId('install-from-github')).not.toBeInTheDocument() - expect(screen.queryByTestId('install-from-local-package')).not.toBeInTheDocument() - }) - - it('should update selectedAction when github button is clicked', async () => { + // ==================== Install Methods Tests (useEffect) ==================== + describe('Install Methods (useEffect)', () => { + it('should render all three install methods when marketplace enabled and not restricted', async () => { // Arrange - render() - - // Act - const githubButton = screen.getByText('plugin.source.github') - await userEvent.click(githubButton) - - // Assert - expect(screen.getByTestId('install-from-github')).toBeInTheDocument() - }) - - it('should clear selectedAction when github modal is closed', async () => { - // Arrange - render() - - // Act - Open then close - await userEvent.click(screen.getByText('plugin.source.github')) - expect(screen.getByTestId('install-from-github')).toBeInTheDocument() - - await userEvent.click(screen.getByTestId('github-close')) - - // Assert - expect(screen.queryByTestId('install-from-github')).not.toBeInTheDocument() - }) - - it('should update selectedFile when file is selected', async () => { - // Arrange - render() - const file = new File(['test content'], 'test.difypkg', { type: 'application/octet-stream' }) - - // Act - const localButton = screen.getByText('plugin.source.local') - await userEvent.click(localButton) - - // Get the hidden file input and simulate file selection - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - expect(fileInput).toBeInTheDocument() - - // Simulate file change - Object.defineProperty(fileInput, 'files', { - value: [file], - configurable: true, - }) - fireEvent.change(fileInput) - - // Assert - await waitFor(() => { - const localPackageModal = screen.getByTestId('install-from-local-package') - expect(localPackageModal).toBeInTheDocument() - expect(localPackageModal).toHaveAttribute('data-filename', 'test.difypkg') - }) - }) - - it('should clear selectedAction when local package modal is closed', async () => { - // Arrange - render() - const file = new File(['test'], 'test.difypkg', { type: 'application/octet-stream' }) - - // Act - Select file - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - Object.defineProperty(fileInput, 'files', { - value: [file], - configurable: true, - }) - fireEvent.change(fileInput) - - await waitFor(() => { - expect(screen.getByTestId('install-from-local-package')).toBeInTheDocument() - }) - - // Close the modal - await userEvent.click(screen.getByTestId('local-close')) - - // Assert - expect(screen.queryByTestId('install-from-local-package')).not.toBeInTheDocument() - }) - }) - - // ============================================================================ - // Side Effects and Cleanup Tests - // ============================================================================ - describe('Side Effects and Cleanup', () => { - it('should update install methods when system features change', () => { - // Arrange - mockSystemFeatures = createDefaultSystemFeatures({ - enable_marketplace: false, + setMockSystemFeatures({ + enable_marketplace: true, plugin_installation_permission: { plugin_installation_scope: InstallationScope.ALL, restrict_to_marketplace_only: false, @@ -276,16 +228,25 @@ describe('Empty', () => { // Act render() + await flushEffects() - // Assert - Marketplace option should not be visible - expect(screen.queryByText('plugin.source.marketplace')).not.toBeInTheDocument() + // 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 only show marketplace when restrict_to_marketplace_only is true', () => { + it('should render only marketplace method when restricted to marketplace only', async () => { // Arrange - mockSystemFeatures = createDefaultSystemFeatures({ + setMockSystemFeatures({ enable_marketplace: true, plugin_installation_permission: { plugin_installation_scope: InstallationScope.ALL, @@ -295,280 +256,189 @@ describe('Empty', () => { // 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() }) - }) - // ============================================================================ - // Memoization Logic Tests - // ============================================================================ - describe('Memoization Logic', () => { - it('should show noInstalled text when plugin list is empty', () => { + it('should render github and local methods when marketplace is disabled', async () => { // Arrange - mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([])) + setMockSystemFeatures({ + enable_marketplace: false, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + }) // Act render() + await flushEffects() // Assert - expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() + 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 show notFound text when filters are active with no results', () => { + it('should render no methods when marketplace disabled and restricted', async () => { // Arrange - mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([{ id: '1' }])) - mockFilters = createFilterState({ categories: ['model'] }) + setMockSystemFeatures({ + enable_marketplace: false, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: true, + }, + }) // Act render() + await flushEffects() // Assert - expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() - }) - - it('should show notFound text when tags filter is active', () => { - // Arrange - mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([{ id: '1' }])) - mockFilters = createFilterState({ tags: ['agent'] }) - - // Act - render() - - // Assert - expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() - }) - - it('should show notFound text when searchQuery is active', () => { - // Arrange - mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([{ id: '1' }])) - mockFilters = createFilterState({ searchQuery: 'test' }) - - // Act - render() - - // Assert - expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() - }) - - it('should update text based on filter and plugin list state', () => { - // Test noInstalled when plugin list is empty - mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([])) - mockFilters = createFilterState() - - const { unmount } = render() - expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() - unmount() - - // Test notFound when filters are active with plugins present - mockFilters = createFilterState({ categories: ['model'] }) - mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([{ id: '1' }])) - - render() - expect(screen.getByText('plugin.list.notFound')).toBeInTheDocument() + const buttons = screen.queryAllByRole('button') + expect(buttons).toHaveLength(0) }) }) - // ============================================================================ - // User Interactions Tests - // ============================================================================ + // ==================== User Interactions Tests ==================== describe('User Interactions', () => { - it('should navigate to discover tab when marketplace button is clicked', async () => { + it('should call setActiveTab with "discover" when marketplace button is clicked', async () => { // Arrange - mockSystemFeatures = createDefaultSystemFeatures({ enable_marketplace: true }) render() + await flushEffects() // Act - await userEvent.click(screen.getByText('plugin.source.marketplace')) + 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 - await userEvent.click(screen.getByText('plugin.source.local')) + fireEvent.click(screen.getByText('plugin.source.local')) // Assert expect(clickSpy).toHaveBeenCalled() }) - it('should not show modals for unselected file', async () => { + it('should open and close local modal when file is selected', async () => { // Arrange render() - - // Act - Click local but don't select a file + await flushEffects() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - fireEvent.change(fileInput, { target: { files: [] } }) + const mockFile = createMockFile('test-plugin.difypkg') - // Assert - expect(screen.queryByTestId('install-from-local-package')).not.toBeInTheDocument() + // 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 handle file input change event', async () => { + it('should not open local modal when no file is selected', async () => { // Arrange render() - const testFile = new File(['content'], 'plugin.difypkg', { type: 'application/octet-stream' }) - - // Act + await flushEffects() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - Object.defineProperty(fileInput, 'files', { - value: [testFile], - configurable: true, - }) + + // Act - trigger change with empty files + Object.defineProperty(fileInput, 'files', { value: [], writable: true }) fireEvent.change(fileInput) // Assert - await waitFor(() => { - expect(screen.getByTestId('install-from-local-package')).toBeInTheDocument() - }) - }) - - it('should render buttons with correct styling', () => { - // Arrange & Act - render() - - // Assert - const buttons = screen.getAllByRole('button') - buttons.forEach((button) => { - expect(button).toHaveClass('justify-start') - }) + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() }) }) - // ============================================================================ - // Component Memoization Tests - // ============================================================================ - describe('Component Memoization', () => { - it('should not rerender when unrelated props change', () => { - // Arrange - const renderCount = vi.fn() - const TestWrapper = () => { - renderCount() - return - } - - // Act - const { rerender } = render() - rerender() - - // Assert - Initial render + rerender - expect(renderCount).toHaveBeenCalledTimes(2) - }) - - it('should maintain stable reference for install methods', () => { - // Arrange & Act - const { container, rerender } = render() - const initialButtons = container.querySelectorAll('button') - - rerender() - const afterRerenderButtons = container.querySelectorAll('button') - - // Assert - expect(initialButtons.length).toBe(afterRerenderButtons.length) - }) - }) - - // ============================================================================ - // Edge Cases Tests - // ============================================================================ - describe('Edge Cases', () => { - it('should handle undefined plugin list data', () => { - // Arrange - mockUseInstalledPluginList.mockReturnValue({ data: undefined }) - - // Act - render() - - // Assert - Should still render without crashing - expect(screen.getByRole('button', { name: /github/i })).toBeInTheDocument() - }) - - it('should handle null plugin list', () => { - // Arrange - mockUseInstalledPluginList.mockReturnValue({ data: null }) - - // Act - render() - - // Assert - expect(document.querySelector('.relative.z-0.w-full.grow')).toBeInTheDocument() - }) - - it('should handle empty system features', () => { - // Arrange - mockSystemFeatures = { - enable_marketplace: false, - plugin_installation_permission: { - plugin_installation_scope: InstallationScope.NONE, - restrict_to_marketplace_only: false, - }, - } - - // Act - render() - - // Assert - expect(screen.getByText('plugin.source.github')).toBeInTheDocument() - }) - - it('should handle multiple file selection attempts', async () => { + // ==================== State Management Tests ==================== + describe('State Management', () => { + it('should maintain modal state correctly and allow reopening', async () => { // Arrange render() - const file1 = new File(['content1'], 'plugin1.difypkg') - const file2 = new File(['content2'], 'plugin2.difypkg') + await flushEffects() - // Act - First file + // 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 - Object.defineProperty(fileInput, 'files', { value: [file1], configurable: true }) + + // 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') - await waitFor(() => { - expect(screen.getByTestId('install-from-local-package')).toHaveAttribute('data-filename', 'plugin1.difypkg') - }) - - // Close and select another - await userEvent.click(screen.getByTestId('local-close')) - - Object.defineProperty(fileInput, 'files', { value: [file2], configurable: true }) + // 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) - - // Assert - await waitFor(() => { - expect(screen.getByTestId('install-from-local-package')).toHaveAttribute('data-filename', 'plugin2.difypkg') - }) - }) - - it('should handle rapid button clicks', async () => { - // Arrange - const user = userEvent.setup() - render() - - // Act - Rapidly click different buttons - await user.click(screen.getByText('plugin.source.github')) - await user.click(screen.getByTestId('github-close')) - await user.click(screen.getByText('plugin.source.marketplace')) - - // Assert - expect(mockSetActiveTab).toHaveBeenCalledWith('discover') + expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-bundle.difybndl') }) }) - // ============================================================================ - // Prop Variations Tests - // ============================================================================ - describe('Prop Variations', () => { - it('should render all install methods when all permissions are granted', () => { - // Arrange - mockSystemFeatures = createDefaultSystemFeatures({ + // ==================== 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, @@ -576,247 +446,127 @@ describe('Empty', () => { }, }) - // Act - render() + const { rerender } = render() + await flushEffects() - // Assert - expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() - expect(screen.getByText('plugin.source.github')).toBeInTheDocument() - expect(screen.getByText('plugin.source.local')).toBeInTheDocument() - }) + // Assert initial state - 3 methods + expect(screen.getAllByRole('button')).toHaveLength(3) - it('should render only marketplace when restricted', () => { - // Arrange - mockSystemFeatures = createDefaultSystemFeatures({ + // Act - Restrict to marketplace only + setMockSystemFeatures({ enable_marketplace: true, plugin_installation_permission: { - plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, + plugin_installation_scope: InstallationScope.ALL, restrict_to_marketplace_only: true, }, }) + rerender() + await flushEffects() - // Act - render() - - // Assert + // Assert - Only marketplace button + expect(screen.getAllByRole('button')).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 when marketplace is disabled', () => { + it('should update text when pluginList or filters change', async () => { // Arrange - mockSystemFeatures = createDefaultSystemFeatures({ - enable_marketplace: false, - plugin_installation_permission: { - plugin_installation_scope: InstallationScope.ALL, - restrict_to_marketplace_only: false, - }, - }) + setMockPluginList({ plugins: [] }) + const { rerender } = render() + await flushEffects() - // Act - render() + // Assert initial state + expect(screen.getByText('plugin.list.noInstalled')).toBeInTheDocument() - // Assert - expect(screen.queryByText('plugin.source.marketplace')).not.toBeInTheDocument() - expect(screen.getByText('plugin.source.github')).toBeInTheDocument() - expect(screen.getByText('plugin.source.local')).toBeInTheDocument() - }) - - it('should handle filter combinations for text display', () => { - // Arrange - mockUseInstalledPluginList.mockReturnValue(createPluginListResponse([{ id: '1' }])) - mockFilters = createFilterState({ - categories: ['model'], - tags: ['agent'], - searchQuery: 'test', - }) - - // Act - render() + // 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() }) }) - // ============================================================================ - // API Calls Tests - // ============================================================================ - describe('API Calls', () => { - it('should call useInstalledPluginList on mount', () => { - // Arrange & Act - render() + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it('should handle undefined/null plugin data gracefully', () => { + // Test undefined plugin list + setMockPluginList(undefined) + expect(() => render()).not.toThrow() - // Assert - expect(mockUseInstalledPluginList).toHaveBeenCalled() + // Test null plugins array + mockUseInstalledPluginList.mockReturnValue({ data: { plugins: null as any } }) + expect(() => render()).not.toThrow() }) - it('should handle plugin list loading state', () => { + it('should handle file input edge cases', async () => { // Arrange - mockUseInstalledPluginList.mockReturnValue({ - data: undefined, - isLoading: true, - }) - - // Act render() + await flushEffects() + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - // Assert - Component should still render - expect(document.querySelector('.relative.z-0.w-full.grow')).toBeInTheDocument() - }) - - it('should handle plugin list error state', () => { - // Arrange - mockUseInstalledPluginList.mockReturnValue({ - data: undefined, - error: new Error('Failed to fetch'), - }) - - // Act - render() - - // Assert - Component should still render gracefully - expect(document.querySelector('.relative.z-0.w-full.grow')).toBeInTheDocument() + // Test undefined files + Object.defineProperty(fileInput, 'files', { value: undefined, writable: true }) + fireEvent.change(fileInput) + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() }) }) - // ============================================================================ - // File Input Tests - // ============================================================================ - describe('File Input', () => { - it('should have correct accept attribute', () => { - // Arrange & Act - render() - + // ==================== React.memo Tests ==================== + describe('React.memo Behavior', () => { + it('should be wrapped with React.memo and have displayName', () => { // Assert - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - expect(fileInput).toHaveAttribute('accept', '.difypkg,.difybndl') - }) - - it('should be hidden from view', () => { - // Arrange & Act - render() - - // Assert - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - expect(fileInput).toHaveStyle({ display: 'none' }) - }) - - it('should handle file input ref correctly', async () => { - // Arrange - render() - - // Act - const localButton = screen.getByText('plugin.source.local') - await userEvent.click(localButton) - - // Assert - File input should have been accessed via ref - const fileInput = document.querySelector('input[type="file"]') - expect(fileInput).toBeInTheDocument() + expect(Empty).toBeDefined() + expect((Empty as any).$$typeof?.toString()).toContain('Symbol') + expect((Empty as any).displayName || (Empty as any).type?.displayName).toBeDefined() }) }) - // ============================================================================ - // Integration Tests - // ============================================================================ - describe('Integration', () => { - it('should complete full workflow: click github -> close modal', async () => { + // ==================== Modal Callbacks Tests ==================== + describe('Modal Callbacks', () => { + it('should handle modal onSuccess callbacks (noop)', async () => { // Arrange render() + await flushEffects() - // Act - await userEvent.click(screen.getByText('plugin.source.github')) - expect(screen.getByTestId('install-from-github')).toBeInTheDocument() + // 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() - await userEvent.click(screen.getByTestId('github-close')) + // Close GitHub modal and test Local modal onSuccess + fireEvent.click(screen.getByTestId('github-modal-close')) - // Assert - expect(screen.queryByTestId('install-from-github')).not.toBeInTheDocument() - }) - - it('should complete full workflow: select file -> close modal', async () => { - // Arrange - render() - const testFile = new File(['test'], 'test.difypkg') - - // Act const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - Object.defineProperty(fileInput, 'files', { value: [testFile], configurable: true }) + Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-plugin.difypkg')], writable: true }) fireEvent.change(fileInput) - await waitFor(() => { - expect(screen.getByTestId('install-from-local-package')).toBeInTheDocument() - }) - - await userEvent.click(screen.getByTestId('local-close')) - - // Assert - expect(screen.queryByTestId('install-from-local-package')).not.toBeInTheDocument() - }) - - it('should switch between modals correctly', async () => { - // Arrange - render() - - // Act - Open GitHub modal - await userEvent.click(screen.getByText('plugin.source.github')) - expect(screen.getByTestId('install-from-github')).toBeInTheDocument() - - // Close and navigate to marketplace - await userEvent.click(screen.getByTestId('github-close')) - await userEvent.click(screen.getByText('plugin.source.marketplace')) - - // Assert - expect(mockSetActiveTab).toHaveBeenCalledWith('discover') - expect(screen.queryByTestId('install-from-github')).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId('local-modal-success')) + expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument() }) }) - // ============================================================================ - // Conditional Rendering Tests - // ============================================================================ - describe('Conditional Rendering', () => { - it('should show github modal only when selectedAction is github', async () => { + // ==================== 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 - Initially hidden - expect(screen.queryByTestId('install-from-github')).not.toBeInTheDocument() + // Assert - no modals initially + expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() - // Act - await userEvent.click(screen.getByText('plugin.source.github')) + // 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() - // Assert - Now visible - expect(screen.getByTestId('install-from-github')).toBeInTheDocument() - }) - - it('should show local package modal only when action is local AND file is selected', async () => { - // Arrange - render() - - // Assert - Initially hidden - expect(screen.queryByTestId('install-from-local-package')).not.toBeInTheDocument() - - // Act - Click local button (triggers file picker, but no file selected yet) - // We need to simulate file selection - const testFile = new File(['content'], 'test.difypkg') - const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement - Object.defineProperty(fileInput, 'files', { value: [testFile], configurable: true }) - fireEvent.change(fileInput) - - // Assert - Now visible with file - await waitFor(() => { - expect(screen.getByTestId('install-from-local-package')).toBeInTheDocument() - }) - }) - - it('should not show local package modal if no file is selected', () => { - // Arrange - render() - - // Assert - expect(screen.queryByTestId('install-from-local-package')).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/plugin-tasks/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx deleted file mode 100644 index a387830ff1..0000000000 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx +++ /dev/null @@ -1,1358 +0,0 @@ -import type { PluginStatus } from '@/app/components/plugins/types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { TaskStatus } from '@/app/components/plugins/types' - -// ============================================================================ -// Import Components After Mocks -// ============================================================================ - -import PluginTasks from './index' -import { - ErrorPluginsSection, - RunningPluginsSection, - SuccessPluginsSection, -} from './plugin-task-list' -import PluginTaskTrigger from './plugin-task-trigger' - -// ============================================================================ -// Mock Setup -// ============================================================================ - -// Stable translation function to avoid re-render issues -const stableT = (key: string, params?: Record) => { - if (params) { - let result = key - Object.entries(params).forEach(([k, v]) => { - result = result.replace(new RegExp(`{{${k}}}`, 'g'), String(v)) - }) - return result - } - return key -} - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: stableT, - i18n: { language: 'en', changeLanguage: vi.fn() }, - }), -})) - -// Mock language context - prevent es-toolkit import error -vi.mock('@/context/i18n', () => ({ - useGetLanguage: () => 'en-US', -})) - -// Mock icon utility -vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({ - default: () => ({ - getIconUrl: (icon: string) => icon || 'default-icon.png', - }), -})) - -// Mock CardIcon component -vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ - default: ({ src, size }: { src: string, size: string }) => ( -
- ), -})) - -// Mock DownloadingIcon -vi.mock('@/app/components/header/plugins-nav/downloading-icon', () => ({ - default: () =>
, -})) - -// Mock ProgressCircle -vi.mock('@/app/components/base/progress-bar/progress-circle', () => ({ - default: ({ percentage }: { percentage: number }) => ( -
- ), -})) - -// Mock Tooltip - to avoid nested portal structure -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children }: { children: React.ReactNode }) => <>{children}, -})) - -// Mock portal component with shared state for open/close behavior -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 }: { children: React.ReactNode }) => { - if (!mockPortalOpenState) - return null - return
{children}
- }, -})) - -// Mock plugin task hooks -const mockHandleRefetch = vi.fn() -const mockMutateAsync = vi.fn() - -let mockPluginTasks: Array<{ - id: string - plugins: Array> -}> = [] - -vi.mock('@/service/use-plugins', () => ({ - usePluginTaskList: () => ({ - pluginTasks: mockPluginTasks, - handleRefetch: mockHandleRefetch, - }), - useMutationClearTaskPlugin: () => ({ - mutateAsync: mockMutateAsync, - }), -})) - -// ============================================================================ -// Test Data Factories -// ============================================================================ - -const createPluginStatus = (overrides: Partial = {}): PluginStatus => ({ - plugin_unique_identifier: `plugin-${Math.random().toString(36).slice(2, 9)}`, - plugin_id: 'plugin-id-1', - status: TaskStatus.running, - message: '', - icon: 'icon.png', - labels: { - 'en-US': 'Test Plugin', - 'zh-Hans': '测试插件', - } as Record, - taskId: 'task-1', - ...overrides, -}) - -const createRunningPlugin = (overrides: Partial = {}): PluginStatus => - createPluginStatus({ status: TaskStatus.running, message: '', ...overrides }) - -const createSuccessPlugin = (overrides: Partial = {}): PluginStatus => - createPluginStatus({ status: TaskStatus.success, message: 'Installed successfully', ...overrides }) - -const createErrorPlugin = (overrides: Partial = {}): PluginStatus => - createPluginStatus({ status: TaskStatus.failed, message: 'Installation failed', ...overrides }) - -const createPluginTask = ( - id: string, - plugins: Array>, -) => ({ - id, - plugins, -}) - -// ============================================================================ -// PluginTasks Main Component Tests -// ============================================================================ - -describe('PluginTasks', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockPluginTasks = [] - mockMutateAsync.mockResolvedValue({}) - }) - - // -------------------------------------------------------------------------- - // Rendering Tests - verify component renders correctly in different states - // -------------------------------------------------------------------------- - describe('Rendering', () => { - it('should return null when there are no plugin tasks', () => { - // Arrange - mockPluginTasks = [] - - // Act - const { container } = render() - - // Assert - expect(container.firstChild).toBeNull() - }) - - it('should render trigger button when there are running plugins', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [createRunningPlugin()])] - - // Act - render() - - // Assert - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() - expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() - }) - - it('should render trigger button when there are success plugins', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [createSuccessPlugin()])] - - // Act - render() - - // Assert - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() - }) - - it('should render trigger button when there are error plugins', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [createErrorPlugin()])] - - // Act - render() - - // Assert - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() - }) - - it('should render trigger button with mixed plugin states', () => { - // Arrange - mockPluginTasks = [ - createPluginTask('task-1', [ - createRunningPlugin({ plugin_unique_identifier: 'plugin-1' }), - createSuccessPlugin({ plugin_unique_identifier: 'plugin-2' }), - createErrorPlugin({ plugin_unique_identifier: 'plugin-3' }), - ]), - ] - - // Act - render() - - // Assert - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // User Interactions - test click handlers and panel behavior - // -------------------------------------------------------------------------- - describe('User Interactions', () => { - it('should open panel when clicking trigger with running plugins', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [createRunningPlugin()])] - render() - - // Act - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Assert - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) - - it('should open panel when clicking trigger with success plugins', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [createSuccessPlugin()])] - render() - - // Act - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Assert - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) - - it('should open panel when clicking trigger with error plugins', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [createErrorPlugin()])] - render() - - // Act - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Assert - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) - - it('should toggle panel on repeated clicks', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [createSuccessPlugin()])] - render() - - // Act - first click opens - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Assert - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - - // Act - second click closes - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Assert - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() - }) - - it('should show running plugins section when panel is open', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [ - createRunningPlugin({ labels: { 'en-US': 'Running Plugin 1' } as Record }), - ])] - render() - - // Act - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Assert - expect(screen.getByText('plugin.task.installing')).toBeInTheDocument() - expect(screen.getByText('Running Plugin 1')).toBeInTheDocument() - }) - - it('should show success plugins section when panel is open', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [ - createSuccessPlugin({ labels: { 'en-US': 'Success Plugin 1' } as Record }), - ])] - render() - - // Act - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Assert - text may be split across elements, use container query - expect(screen.getByText(/plugin\.task\.installed/)).toBeInTheDocument() - expect(screen.getByText('Success Plugin 1')).toBeInTheDocument() - }) - - it('should show error plugins section when panel is open', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [ - createErrorPlugin({ - labels: { 'en-US': 'Error Plugin 1' } as Record, - message: 'Failed to install', - }), - ])] - render() - - // Act - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Assert - expect(screen.getByText(/plugin.task.installError/)).toBeInTheDocument() - expect(screen.getByText('Error Plugin 1')).toBeInTheDocument() - expect(screen.getByText('Failed to install')).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Clear Actions - test clearing plugins - // -------------------------------------------------------------------------- - describe('Clear Actions', () => { - it('should call clear handler when clicking clear all on success section', async () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [ - createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), - ])] - render() - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Act - const clearAllButtons = screen.getAllByText('plugin.task.clearAll') - fireEvent.click(clearAllButtons[0]) - - // Assert - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalled() - }) - }) - - it('should call clear handler when clicking clear all on error section', async () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [ - createErrorPlugin({ plugin_unique_identifier: 'error-1' }), - ])] - render() - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Act - const clearAllButton = screen.getByText('plugin.task.clearAll') - fireEvent.click(clearAllButton) - - // Assert - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalled() - }) - }) - - it('should call clear single when clicking clear on individual error plugin', async () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [ - createErrorPlugin({ plugin_unique_identifier: 'error-1', taskId: 'task-1' }), - ])] - render() - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Act - const clearButton = screen.getByText('common.operation.clear') - fireEvent.click(clearButton) - - // Assert - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledWith({ - taskId: 'task-1', - pluginId: 'error-1', - }) - }) - }) - }) - - // -------------------------------------------------------------------------- - // Multiple Tasks - test handling of plugins from multiple tasks - // -------------------------------------------------------------------------- - describe('Multiple Tasks', () => { - it('should aggregate plugins from multiple tasks', () => { - // Arrange - mockPluginTasks = [ - createPluginTask('task-1', [ - createRunningPlugin({ plugin_unique_identifier: 'plugin-1', labels: { 'en-US': 'Plugin 1' } as Record }), - ]), - createPluginTask('task-2', [ - createSuccessPlugin({ plugin_unique_identifier: 'plugin-2', labels: { 'en-US': 'Plugin 2' } as Record }), - ]), - ] - render() - - // Act - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Assert - expect(screen.getByText('Plugin 1')).toBeInTheDocument() - expect(screen.getByText('Plugin 2')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// PluginTaskTrigger Component Tests -// ============================================================================ - -describe('PluginTaskTrigger', () => { - const defaultProps = { - tip: 'Test tip', - isInstalling: false, - isInstallingWithSuccess: false, - isInstallingWithError: false, - isSuccess: false, - isFailed: false, - successPluginsLength: 0, - runningPluginsLength: 0, - errorPluginsLength: 0, - totalPluginsLength: 1, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - // -------------------------------------------------------------------------- - // Rendering Tests - verify icons and indicators render correctly - // -------------------------------------------------------------------------- - describe('Rendering', () => { - it('should render with default props', () => { - // Arrange & Act - render() - - // Assert - expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() - }) - - it('should render downloading icon when isInstalling is true', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByTestId('downloading-icon')).toBeInTheDocument() - }) - - it('should render downloading icon when isInstallingWithError is true', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByTestId('downloading-icon')).toBeInTheDocument() - }) - - it('should render install icon when not installing', () => { - // Arrange & Act - const { container } = render() - - // Assert - RiInstallLine should be rendered (not downloading icon) - expect(screen.queryByTestId('downloading-icon')).not.toBeInTheDocument() - expect(container.querySelector('svg')).toBeInTheDocument() - }) - - it('should render progress circle when isInstalling', () => { - // Arrange & Act - render( - , - ) - - // Assert - const progressCircle = screen.getByTestId('progress-circle') - expect(progressCircle).toBeInTheDocument() - expect(progressCircle).toHaveAttribute('data-percentage', '40') - }) - - it('should render progress circle when isInstallingWithSuccess', () => { - // Arrange & Act - render( - , - ) - - // Assert - const progressCircle = screen.getByTestId('progress-circle') - expect(progressCircle).toHaveAttribute('data-percentage', '30') - }) - - it('should render progress circle when isInstallingWithError', () => { - // Arrange & Act - render( - , - ) - - // Assert - const progressCircle = screen.getByTestId('progress-circle') - expect(progressCircle).toHaveAttribute('data-percentage', '40') - }) - - it('should render success indicator when isSuccess', () => { - // Arrange & Act - const { container } = render() - - // Assert - Should have success check icon - expect(container.querySelector('.text-text-success')).toBeInTheDocument() - }) - - it('should render success indicator when all completed with success only', () => { - // Arrange & Act - const { container } = render( - , - ) - - // Assert - expect(container.querySelector('.text-text-success')).toBeInTheDocument() - }) - - it('should render error indicator when isFailed', () => { - // Arrange & Act - const { container } = render() - - // Assert - expect(container.querySelector('.text-text-destructive')).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // CSS Class Tests - verify correct styling based on state - // -------------------------------------------------------------------------- - describe('CSS Classes', () => { - it('should apply error styles when hasError (isFailed)', () => { - // Arrange & Act - render() - - // Assert - const trigger = document.getElementById('plugin-task-trigger') - expect(trigger?.className).toContain('bg-state-destructive-hover') - }) - - it('should apply error styles when hasError (isInstallingWithError)', () => { - // Arrange & Act - render() - - // Assert - const trigger = document.getElementById('plugin-task-trigger') - expect(trigger?.className).toContain('bg-state-destructive-hover') - }) - - it('should apply cursor-pointer when isInstalling', () => { - // Arrange & Act - render() - - // Assert - const trigger = document.getElementById('plugin-task-trigger') - expect(trigger?.className).toContain('cursor-pointer') - }) - - it('should apply cursor-pointer when isSuccess', () => { - // Arrange & Act - render() - - // Assert - const trigger = document.getElementById('plugin-task-trigger') - expect(trigger?.className).toContain('cursor-pointer') - }) - }) -}) - -// ============================================================================ -// Plugin List Section Components Tests -// ============================================================================ - -describe('Plugin List Sections', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // -------------------------------------------------------------------------- - // RunningPluginsSection Tests - // -------------------------------------------------------------------------- - describe('RunningPluginsSection', () => { - it('should return null when plugins array is empty', () => { - // Arrange & Act - const { container } = render() - - // Assert - expect(container.firstChild).toBeNull() - }) - - it('should render running plugins with loader icon', () => { - // Arrange - const plugins = [ - createRunningPlugin({ - plugin_unique_identifier: 'running-1', - labels: { 'en-US': 'Installing Plugin' } as Record, - }), - ] - - // Act - render() - - // Assert - expect(screen.getByText('Installing Plugin')).toBeInTheDocument() - expect(screen.getAllByText('plugin.task.installing').length).toBeGreaterThan(0) - }) - - it('should render multiple running plugins', () => { - // Arrange - const plugins = [ - createRunningPlugin({ plugin_unique_identifier: 'running-1', labels: { 'en-US': 'Plugin A' } as Record }), - createRunningPlugin({ plugin_unique_identifier: 'running-2', labels: { 'en-US': 'Plugin B' } as Record }), - ] - - // Act - render() - - // Assert - expect(screen.getByText('Plugin A')).toBeInTheDocument() - expect(screen.getByText('Plugin B')).toBeInTheDocument() - }) - - it('should display correct count in header', () => { - // Arrange - const plugins = [ - createRunningPlugin({ plugin_unique_identifier: 'running-1' }), - createRunningPlugin({ plugin_unique_identifier: 'running-2' }), - createRunningPlugin({ plugin_unique_identifier: 'running-3' }), - ] - - // Act - const { container } = render() - - // Assert - count is in header text content - const header = container.querySelector('.system-sm-semibold-uppercase') - expect(header?.textContent).toContain('(') - expect(header?.textContent).toContain('3') - }) - }) - - // -------------------------------------------------------------------------- - // SuccessPluginsSection Tests - // -------------------------------------------------------------------------- - describe('SuccessPluginsSection', () => { - const mockOnClearAll = vi.fn() - - beforeEach(() => { - mockOnClearAll.mockClear() - }) - - it('should return null when plugins array is empty', () => { - // Arrange & Act - const { container } = render( - , - ) - - // Assert - expect(container.firstChild).toBeNull() - }) - - it('should render success plugins with check icon', () => { - // Arrange - const plugins = [ - createSuccessPlugin({ - plugin_unique_identifier: 'success-1', - labels: { 'en-US': 'Installed Plugin' } as Record, - message: 'Successfully installed', - }), - ] - - // Act - render() - - // Assert - expect(screen.getByText('Installed Plugin')).toBeInTheDocument() - expect(screen.getByText('Successfully installed')).toBeInTheDocument() - }) - - it('should use default message when plugin message is empty', () => { - // Arrange - const plugins = [ - createSuccessPlugin({ - plugin_unique_identifier: 'success-1', - labels: { 'en-US': 'Plugin' } as Record, - message: '', - }), - ] - - // Act - render() - - // Assert - expect(screen.getByText('plugin.task.installed')).toBeInTheDocument() - }) - - it('should render clear all button', () => { - // Arrange - const plugins = [createSuccessPlugin()] - - // Act - render() - - // Assert - expect(screen.getByText('plugin.task.clearAll')).toBeInTheDocument() - }) - - it('should call onClearAll when clear all button is clicked', () => { - // Arrange - const plugins = [createSuccessPlugin()] - render() - - // Act - fireEvent.click(screen.getByText('plugin.task.clearAll')) - - // Assert - expect(mockOnClearAll).toHaveBeenCalledTimes(1) - }) - - it('should render multiple success plugins', () => { - // Arrange - const plugins = [ - createSuccessPlugin({ plugin_unique_identifier: 'success-1', labels: { 'en-US': 'Plugin X' } as Record }), - createSuccessPlugin({ plugin_unique_identifier: 'success-2', labels: { 'en-US': 'Plugin Y' } as Record }), - ] - - // Act - render() - - // Assert - expect(screen.getByText('Plugin X')).toBeInTheDocument() - expect(screen.getByText('Plugin Y')).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // ErrorPluginsSection Tests - // -------------------------------------------------------------------------- - describe('ErrorPluginsSection', () => { - const mockOnClearAll = vi.fn() - const mockOnClearSingle = vi.fn() - - beforeEach(() => { - mockOnClearAll.mockClear() - mockOnClearSingle.mockClear() - }) - - it('should return null when plugins array is empty', () => { - // Arrange & Act - const { container } = render( - , - ) - - // Assert - expect(container.firstChild).toBeNull() - }) - - it('should render error plugins with error icon', () => { - // Arrange - const plugins = [ - createErrorPlugin({ - plugin_unique_identifier: 'error-1', - labels: { 'en-US': 'Failed Plugin' } as Record, - message: 'Network error', - }), - ] - - // Act - render( - , - ) - - // Assert - expect(screen.getByText('Failed Plugin')).toBeInTheDocument() - expect(screen.getByText('Network error')).toBeInTheDocument() - }) - - it('should render clear all button and call onClearAll when clicked', () => { - // Arrange - const plugins = [createErrorPlugin()] - render( - , - ) - - // Act - fireEvent.click(screen.getByText('plugin.task.clearAll')) - - // Assert - expect(mockOnClearAll).toHaveBeenCalledTimes(1) - }) - - it('should render individual clear button for each error plugin', () => { - // Arrange - const plugins = [ - createErrorPlugin({ plugin_unique_identifier: 'error-1' }), - createErrorPlugin({ plugin_unique_identifier: 'error-2' }), - ] - render( - , - ) - - // Assert - const clearButtons = screen.getAllByText('common.operation.clear') - expect(clearButtons).toHaveLength(2) - }) - - it('should call onClearSingle with correct params when individual clear is clicked', () => { - // Arrange - const plugins = [ - createErrorPlugin({ - plugin_unique_identifier: 'error-plugin-123', - taskId: 'task-456', - }), - ] - render( - , - ) - - // Act - fireEvent.click(screen.getByText('common.operation.clear')) - - // Assert - expect(mockOnClearSingle).toHaveBeenCalledWith('task-456', 'error-plugin-123') - }) - - it('should display error count in header', () => { - // Arrange - const plugins = [ - createErrorPlugin({ plugin_unique_identifier: 'error-1' }), - createErrorPlugin({ plugin_unique_identifier: 'error-2' }), - ] - - // Act - render( - , - ) - - // Assert - expect(screen.getByText(/plugin.task.installError/)).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Edge Cases and Error Handling -// ============================================================================ - -describe('Edge Cases', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockPluginTasks = [] - }) - - it('should handle plugin with missing labels gracefully', () => { - // Arrange - const pluginWithMissingLabel = createRunningPlugin({ - labels: {} as Record, - }) - mockPluginTasks = [createPluginTask('task-1', [pluginWithMissingLabel])] - - // Act & Assert - should not throw - expect(() => { - render() - }).not.toThrow() - }) - - it('should handle empty icon URL', () => { - // Arrange - const pluginWithNoIcon = createRunningPlugin({ - icon: '', - }) - mockPluginTasks = [createPluginTask('task-1', [pluginWithNoIcon])] - render() - - // Act - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Assert - const cardIcon = screen.getByTestId('card-icon') - expect(cardIcon).toHaveAttribute('data-src', 'default-icon.png') - }) - - it('should handle very long error messages', () => { - // Arrange - const longMessage = 'A'.repeat(500) - const pluginWithLongMessage = createErrorPlugin({ - message: longMessage, - }) - mockPluginTasks = [createPluginTask('task-1', [pluginWithLongMessage])] - render() - - // Act - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Assert - expect(screen.getByText(longMessage)).toBeInTheDocument() - }) - - it('should attempt to clear plugin even when API fails', async () => { - // Arrange - API will fail but we should still call it - mockPluginTasks = [createPluginTask('task-1', [createErrorPlugin()])] - render() - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Act - const clearButton = screen.getByText('common.operation.clear') - fireEvent.click(clearButton) - - // Assert - mutation should be called - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalled() - }) - }) - - it('should calculate progress correctly with zero total', () => { - // Arrange & Act - render( - , - ) - - // Assert - should handle division by zero - const progressCircle = screen.getByTestId('progress-circle') - expect(progressCircle).toHaveAttribute('data-percentage', 'NaN') - }) - - it('should handle rapid toggle clicks', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [createSuccessPlugin()])] - render() - const trigger = screen.getByTestId('portal-trigger') - - // Act - rapid clicks - for (let i = 0; i < 5; i++) - fireEvent.click(trigger) - - // Assert - should end in open state (odd number of clicks) - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) -}) - -// ============================================================================ -// Tooltip Integration Tests -// ============================================================================ - -describe('Tooltip Tips', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockPluginTasks = [] - }) - - it('should show installing tip when only running', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [createRunningPlugin()])] - - // Act - render() - - // Assert - tip is passed to trigger, we just verify render doesn't break - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() - }) - - it('should show success tip when all success', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [createSuccessPlugin()])] - - // Act - render() - - // Assert - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() - }) - - it('should show error tip when has failures', () => { - // Arrange - mockPluginTasks = [createPluginTask('task-1', [createErrorPlugin()])] - - // Act - render() - - // Assert - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() - }) - - it('should show installingWithSuccess tip when running with success', () => { - // Arrange - running + success = isInstallingWithSuccess - mockPluginTasks = [createPluginTask('task-1', [ - createRunningPlugin({ plugin_unique_identifier: 'running-1' }), - createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), - ])] - - // Act - render() - - // Assert - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() - }) - - it('should show installingWithError tip when running with errors', () => { - // Arrange - running + error = isInstallingWithError - mockPluginTasks = [createPluginTask('task-1', [ - createRunningPlugin({ plugin_unique_identifier: 'running-1' }), - createErrorPlugin({ plugin_unique_identifier: 'error-1' }), - ])] - - // Act - render() - - // Assert - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() - }) -}) - -// ============================================================================ -// usePluginTaskPanel Hook Branch Coverage Tests -// ============================================================================ - -describe('usePluginTaskPanel Branch Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockPluginTasks = [] - mockMutateAsync.mockResolvedValue({}) - }) - - // -------------------------------------------------------------------------- - // closeIfNoRunning branch - when runningPluginsLength > 0 - // -------------------------------------------------------------------------- - describe('closeIfNoRunning branch', () => { - it('should NOT close panel when there are still running plugins after clearing', async () => { - // Arrange - 2 running plugins, 1 success plugin - mockPluginTasks = [createPluginTask('task-1', [ - createRunningPlugin({ plugin_unique_identifier: 'running-1' }), - createRunningPlugin({ plugin_unique_identifier: 'running-2' }), - createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), - ])] - render() - - // Open the panel - fireEvent.click(screen.getByTestId('portal-trigger')) - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - - // Act - click clear all (clears success plugins) - const clearAllButton = screen.getByText('plugin.task.clearAll') - fireEvent.click(clearAllButton) - - // Assert - panel should still be open because there are running plugins - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalled() - }) - // Panel remains open due to running plugins (runningPluginsLength > 0) - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) - - it('should close panel when no running plugins remain after clearing', async () => { - // Arrange - only success plugins (no running) - mockPluginTasks = [createPluginTask('task-1', [ - createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), - ])] - render() - - // Open the panel - fireEvent.click(screen.getByTestId('portal-trigger')) - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - - // Act - click clear all - const clearAllButton = screen.getByText('plugin.task.clearAll') - fireEvent.click(clearAllButton) - - // Assert - mutation should be called - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalled() - }) - }) - }) - - // -------------------------------------------------------------------------- - // handleClearErrors branch - // -------------------------------------------------------------------------- - describe('handleClearErrors', () => { - it('should clear only error plugins when clicking clear all in error section', async () => { - // Arrange - mix of success and error plugins - mockPluginTasks = [createPluginTask('task-1', [ - createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), - createErrorPlugin({ plugin_unique_identifier: 'error-1' }), - createErrorPlugin({ plugin_unique_identifier: 'error-2' }), - ])] - render() - - // Open the panel - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Act - click clear all in error section (second clearAll button) - const clearAllButtons = screen.getAllByText('plugin.task.clearAll') - // Error section clear button - fireEvent.click(clearAllButtons[1]) - - // Assert - should clear error plugins - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledTimes(2) // 2 error plugins - }) - }) - }) - - // -------------------------------------------------------------------------- - // handleClearAll clears both success and error plugins - // -------------------------------------------------------------------------- - describe('handleClearAll', () => { - it('should clear both success and error plugins', async () => { - // Arrange - mix of success and error plugins - mockPluginTasks = [createPluginTask('task-1', [ - createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), - createSuccessPlugin({ plugin_unique_identifier: 'success-2' }), - createErrorPlugin({ plugin_unique_identifier: 'error-1' }), - ])] - render() - - // Open the panel - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Act - click clear all in success section (first clearAll button) - const clearAllButtons = screen.getAllByText('plugin.task.clearAll') - fireEvent.click(clearAllButtons[0]) - - // Assert - should clear all completed plugins (3 total: 2 success + 1 error) - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledTimes(3) - }) - }) - }) - - // -------------------------------------------------------------------------- - // canOpenPanel false branch - when no valid state to open panel - // -------------------------------------------------------------------------- - describe('canOpenPanel false branch', () => { - it('should not open panel when clicking trigger in invalid state', () => { - // This branch is difficult to test because the hook logic always returns - // a valid state when there are plugins. The canOpenPanel is computed from - // isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess - // which covers all possible states when totalPluginsLength > 0 - - // The only way canOpenPanel can be false is if all status flags are false, - // which theoretically shouldn't happen with the current hook logic - // This test documents the behavior - mockPluginTasks = [createPluginTask('task-1', [createRunningPlugin()])] - render() - - // Initial state - panel should be closed - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() - - // Click trigger - should open because isInstalling is true - fireEvent.click(screen.getByTestId('portal-trigger')) - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Tip message variations - all branches - // -------------------------------------------------------------------------- - describe('tip message branches', () => { - it('should compute isInstallingWithSuccess tip correctly', () => { - // Arrange - running > 0, success > 0, error = 0 triggers isInstallingWithSuccess - mockPluginTasks = [createPluginTask('task-1', [ - createRunningPlugin({ plugin_unique_identifier: 'running-1' }), - createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), - ])] - - // Act - render() - - // Assert - component renders with isInstallingWithSuccess state - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() - expect(screen.getByTestId('progress-circle')).toBeInTheDocument() - }) - - it('should compute isInstallingWithError tip correctly', () => { - // Arrange - running > 0, error > 0 triggers isInstallingWithError - mockPluginTasks = [createPluginTask('task-1', [ - createRunningPlugin({ plugin_unique_identifier: 'running-1' }), - createErrorPlugin({ plugin_unique_identifier: 'error-1' }), - ])] - - // Act - render() - - // Assert - component renders with error styling - const trigger = document.getElementById('plugin-task-trigger') - expect(trigger?.className).toContain('bg-state-destructive-hover') - }) - - it('should compute isInstalling tip correctly (only running)', () => { - // Arrange - only running plugins - mockPluginTasks = [createPluginTask('task-1', [ - createRunningPlugin({ plugin_unique_identifier: 'running-1' }), - ])] - - // Act - render() - - // Assert - expect(screen.getByTestId('downloading-icon')).toBeInTheDocument() - }) - - it('should compute isFailed tip correctly (errors without running)', () => { - // Arrange - errors only, no running - mockPluginTasks = [createPluginTask('task-1', [ - createErrorPlugin({ plugin_unique_identifier: 'error-1' }), - ])] - - // Act - render() - - // Assert - should show error indicator - const trigger = document.getElementById('plugin-task-trigger') - expect(trigger?.className).toContain('bg-state-destructive-hover') - }) - - it('should compute isSuccess tip correctly (all success)', () => { - // Arrange - all success plugins - mockPluginTasks = [createPluginTask('task-1', [ - createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), - createSuccessPlugin({ plugin_unique_identifier: 'success-2' }), - ])] - - // Act - const { container } = render() - - // Assert - should show success indicator - expect(container.querySelector('.text-text-success')).toBeInTheDocument() - }) - - it('should compute isFailed with mixed success and error (but no running)', () => { - // Arrange - success + error, no running = isFailed (because errorPluginsLength > 0) - mockPluginTasks = [createPluginTask('task-1', [ - createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), - createErrorPlugin({ plugin_unique_identifier: 'error-1' }), - ])] - - // Act - render() - - // Assert - should show error styling because isFailed is true - const trigger = document.getElementById('plugin-task-trigger') - expect(trigger?.className).toContain('bg-state-destructive-hover') - }) - }) - - // -------------------------------------------------------------------------- - // clearPlugins iteration - clearing multiple plugins - // -------------------------------------------------------------------------- - describe('clearPlugins iteration', () => { - it('should clear plugins one by one in order', async () => { - // Arrange - multiple error plugins - mockPluginTasks = [createPluginTask('task-1', [ - createErrorPlugin({ plugin_unique_identifier: 'error-1', taskId: 'task-1' }), - createErrorPlugin({ plugin_unique_identifier: 'error-2', taskId: 'task-1' }), - createErrorPlugin({ plugin_unique_identifier: 'error-3', taskId: 'task-1' }), - ])] - render() - - // Open panel - fireEvent.click(screen.getByTestId('portal-trigger')) - - // Act - click clear all in error section - const clearAllButton = screen.getByText('plugin.task.clearAll') - fireEvent.click(clearAllButton) - - // Assert - should call mutateAsync for each plugin - await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledTimes(3) - }) - - // Verify each plugin was cleared - expect(mockMutateAsync).toHaveBeenNthCalledWith(1, { - taskId: 'task-1', - pluginId: 'error-1', - }) - expect(mockMutateAsync).toHaveBeenNthCalledWith(2, { - taskId: 'task-1', - pluginId: 'error-2', - }) - expect(mockMutateAsync).toHaveBeenNthCalledWith(3, { - taskId: 'task-1', - pluginId: 'error-3', - }) - }) - }) -}) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx index dc7bee7770..b4c1692533 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx @@ -1,27 +1,34 @@ +import { + RiCheckboxCircleFill, + RiErrorWarningFill, + RiInstallLine, + RiLoaderLine, +} from '@remixicon/react' +import { + useCallback, + useMemo, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { - ErrorPluginsSection, - RunningPluginsSection, - SuccessPluginsSection, -} from './plugin-task-list' -import PluginTaskTrigger from './plugin-task-trigger' -import { usePluginTaskPanel } from './use-plugin-task-panel' +import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' +import Tooltip from '@/app/components/base/tooltip' +import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon' +import CardIcon from '@/app/components/plugins/card/base/card-icon' +import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' +import { useGetLanguage } from '@/context/i18n' +import { cn } from '@/utils/classnames' +import { usePluginTaskStatus } from './hooks' const PluginTasks = () => { - const { - open, - setOpen, - tip, - taskStatus, - handleClearAll, - handleClearErrors, - handleClearSingle, - } = usePluginTaskPanel() - + const { t } = useTranslation() + const language = useGetLanguage() + const [open, setOpen] = useState(false) const { errorPlugins, successPlugins, @@ -35,15 +42,68 @@ const PluginTasks = () => { isInstallingWithError, isSuccess, isFailed, - } = taskStatus + handleClearErrorPlugin, + } = usePluginTaskStatus() + const { getIconUrl } = useGetIcon() + + const handleClearAllWithModal = useCallback(async () => { + // Clear all completed plugins (success and error) but keep running ones + const completedPlugins = [...successPlugins, ...errorPlugins] + + // Clear all completed plugins individually + for (const plugin of completedPlugins) + await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier) + + // Only close modal if no plugins are still installing + if (runningPluginsLength === 0) + setOpen(false) + }, [successPlugins, errorPlugins, handleClearErrorPlugin, runningPluginsLength]) + + const handleClearErrorsWithModal = useCallback(async () => { + // Clear only error plugins, not all plugins + for (const plugin of errorPlugins) + await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier) + // Only close modal if no plugins are still installing + if (runningPluginsLength === 0) + setOpen(false) + }, [errorPlugins, handleClearErrorPlugin, runningPluginsLength]) + + const handleClearSingleWithModal = useCallback(async (taskId: string, pluginId: string) => { + await handleClearErrorPlugin(taskId, pluginId) + // Only close modal if no plugins are still installing + if (runningPluginsLength === 0) + setOpen(false) + }, [handleClearErrorPlugin, runningPluginsLength]) + + const tip = useMemo(() => { + if (isInstallingWithError) + return t('plugin.task.installingWithError', { installingLength: runningPluginsLength, successLength: successPluginsLength, errorLength: errorPluginsLength }) + if (isInstallingWithSuccess) + return t('plugin.task.installingWithSuccess', { installingLength: runningPluginsLength, successLength: successPluginsLength }) + if (isInstalling) + return t('plugin.task.installing') + if (isFailed) + return t('plugin.task.installedError', { errorLength: errorPluginsLength }) + if (isSuccess) + return t('plugin.task.installSuccess', { successLength: successPluginsLength }) + return t('plugin.task.installed') + }, [ + errorPluginsLength, + isFailed, + isInstalling, + isInstallingWithError, + isInstallingWithSuccess, + isSuccess, + runningPluginsLength, + successPluginsLength, + t, + ]) // Show icon if there are any plugin tasks (completed, running, or failed) // Only hide when there are absolutely no plugin tasks if (totalPluginsLength === 0) return null - const canOpenPanel = isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess - return (
{ > { - if (canOpenPanel) - setOpen(!open) + if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess) + setOpen(v => !v) }} > - + +
+ { + (isInstalling || isInstallingWithError) && ( + + ) + } + { + !(isInstalling || isInstallingWithError) && ( + + ) + } +
+ { + (isInstalling || isInstallingWithSuccess) && ( + + ) + } + { + isInstallingWithError && ( + + ) + } + { + (isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0 && errorPluginsLength === 0)) && ( + + ) + } + { + isFailed && ( + + ) + } +
+
+
- - - + {/* Running Plugins */} + {runningPlugins.length > 0 && ( + <> +
+ {t('plugin.task.installing')} + {' '} + ( + {runningPlugins.length} + ) +
+
+ {runningPlugins.map(runningPlugin => ( +
+
+ + +
+
+
+ {runningPlugin.labels[language]} +
+
+ {t('plugin.task.installing')} +
+
+
+ ))} +
+ + )} + + {/* Success Plugins */} + {successPlugins.length > 0 && ( + <> +
+ {t('plugin.task.installed')} + {' '} + ( + {successPlugins.length} + ) + +
+
+ {successPlugins.map(successPlugin => ( +
+
+ + +
+
+
+ {successPlugin.labels[language]} +
+
+ {successPlugin.message || t('plugin.task.installed')} +
+
+
+ ))} +
+ + )} + + {/* Error Plugins */} + {errorPlugins.length > 0 && ( + <> +
+ {t('plugin.task.installError', { errorLength: errorPlugins.length })} + +
+
+ {errorPlugins.map(errorPlugin => ( +
+
+ + +
+
+
+ {errorPlugin.labels[language]} +
+
+ {errorPlugin.message} +
+
+ +
+ ))} +
+ + )}
diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-list.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-list.tsx deleted file mode 100644 index 0aa04a707c..0000000000 --- a/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-list.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import type { ReactNode } from 'react' -import type { PluginStatus } from '@/app/components/plugins/types' -import { - RiCheckboxCircleFill, - RiErrorWarningFill, - RiLoaderLine, -} from '@remixicon/react' -import { useTranslation } from 'react-i18next' -import Button from '@/app/components/base/button' -import CardIcon from '@/app/components/plugins/card/base/card-icon' -import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' -import { useGetLanguage } from '@/context/i18n' - -// Plugin item base component -type PluginTaskItemProps = { - plugin: PluginStatus - statusIcon: ReactNode - statusText: string - statusClassName?: string - action?: ReactNode -} - -const PluginTaskItem = ({ - plugin, - statusIcon, - statusText, - statusClassName = 'text-text-tertiary', - action, -}: PluginTaskItemProps) => { - const language = useGetLanguage() - const { getIconUrl } = useGetIcon() - - return ( -
-
- {statusIcon} - -
-
-
- {plugin.labels[language]} -
-
- {statusText} -
-
- {action} -
- ) -} - -// Section header component -type SectionHeaderProps = { - title: string - count: number - action?: ReactNode -} - -const SectionHeader = ({ title, count, action }: SectionHeaderProps) => ( -
- {title} - {' '} - ( - {count} - ) - {action} -
-) - -// Running plugins section -type RunningPluginsSectionProps = { - plugins: PluginStatus[] -} - -export const RunningPluginsSection = ({ plugins }: RunningPluginsSectionProps) => { - const { t } = useTranslation() - - if (plugins.length === 0) - return null - - return ( - <> - -
- {plugins.map(plugin => ( - - } - statusText={t('plugin.task.installing')} - /> - ))} -
- - ) -} - -// Success plugins section -type SuccessPluginsSectionProps = { - plugins: PluginStatus[] - onClearAll: () => void -} - -export const SuccessPluginsSection = ({ plugins, onClearAll }: SuccessPluginsSectionProps) => { - const { t } = useTranslation() - - if (plugins.length === 0) - return null - - return ( - <> - - {t('plugin.task.clearAll')} - - )} - /> -
- {plugins.map(plugin => ( - - } - statusText={plugin.message || t('plugin.task.installed')} - statusClassName="text-text-success" - /> - ))} -
- - ) -} - -// Error plugins section -type ErrorPluginsSectionProps = { - plugins: PluginStatus[] - onClearAll: () => void - onClearSingle: (taskId: string, pluginId: string) => void -} - -export const ErrorPluginsSection = ({ plugins, onClearAll, onClearSingle }: ErrorPluginsSectionProps) => { - const { t } = useTranslation() - - if (plugins.length === 0) - return null - - return ( - <> - - {t('plugin.task.clearAll')} - - )} - /> -
- {plugins.map(plugin => ( - - } - statusText={plugin.message} - statusClassName="break-all text-text-destructive" - action={( - - )} - /> - ))} -
- - ) -} diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-trigger.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-trigger.tsx deleted file mode 100644 index d860854869..0000000000 --- a/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-trigger.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { - RiCheckboxCircleFill, - RiErrorWarningFill, - RiInstallLine, -} from '@remixicon/react' -import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' -import Tooltip from '@/app/components/base/tooltip' -import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon' -import { cn } from '@/utils/classnames' - -type PluginTaskTriggerProps = { - tip: string - isInstalling: boolean - isInstallingWithSuccess: boolean - isInstallingWithError: boolean - isSuccess: boolean - isFailed: boolean - successPluginsLength: number - runningPluginsLength: number - errorPluginsLength: number - totalPluginsLength: number -} - -const PluginTaskTrigger = ({ - tip, - isInstalling, - isInstallingWithSuccess, - isInstallingWithError, - isSuccess, - isFailed, - successPluginsLength, - runningPluginsLength, - errorPluginsLength, - totalPluginsLength, -}: PluginTaskTriggerProps) => { - const showDownloadingIcon = isInstalling || isInstallingWithError - const hasError = isInstallingWithError || isFailed - const showSuccessIndicator = isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0 && errorPluginsLength === 0) - - return ( - -
- {/* Main Icon */} - {showDownloadingIcon - ? - : ( - - )} - - {/* Status Indicator */} -
- {(isInstalling || isInstallingWithSuccess) && ( - - )} - {isInstallingWithError && ( - - )} - {showSuccessIndicator && ( - - )} - {isFailed && ( - - )} -
-
-
- ) -} - -export default PluginTaskTrigger diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/use-plugin-task-panel.ts b/web/app/components/plugins/plugin-page/plugin-tasks/use-plugin-task-panel.ts deleted file mode 100644 index 1b35cd9903..0000000000 --- a/web/app/components/plugins/plugin-page/plugin-tasks/use-plugin-task-panel.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { PluginStatus } from '@/app/components/plugins/types' -import { useCallback, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { usePluginTaskStatus } from './hooks' - -type PluginTaskPanelState = { - open: boolean - setOpen: (open: boolean) => void - tip: string - taskStatus: ReturnType - handleClearAll: () => Promise - handleClearErrors: () => Promise - handleClearSingle: (taskId: string, pluginId: string) => Promise -} - -export const usePluginTaskPanel = (): PluginTaskPanelState => { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - const taskStatus = usePluginTaskStatus() - - const { - errorPlugins, - successPlugins, - runningPluginsLength, - successPluginsLength, - errorPluginsLength, - isInstalling, - isInstallingWithSuccess, - isInstallingWithError, - isSuccess, - isFailed, - handleClearErrorPlugin, - } = taskStatus - - const closeIfNoRunning = useCallback(() => { - if (runningPluginsLength === 0) - setOpen(false) - }, [runningPluginsLength]) - - const clearPlugins = useCallback(async (plugins: PluginStatus[]) => { - for (const plugin of plugins) - await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier) - }, [handleClearErrorPlugin]) - - const handleClearAll = useCallback(async () => { - // Clear all completed plugins (success and error) but keep running ones - await clearPlugins([...successPlugins, ...errorPlugins]) - closeIfNoRunning() - }, [successPlugins, errorPlugins, clearPlugins, closeIfNoRunning]) - - const handleClearErrors = useCallback(async () => { - await clearPlugins(errorPlugins) - closeIfNoRunning() - }, [errorPlugins, clearPlugins, closeIfNoRunning]) - - const handleClearSingle = useCallback(async (taskId: string, pluginId: string) => { - await handleClearErrorPlugin(taskId, pluginId) - closeIfNoRunning() - }, [handleClearErrorPlugin, closeIfNoRunning]) - - const tip = useMemo(() => { - if (isInstallingWithError) - return t('plugin.task.installingWithError', { installingLength: runningPluginsLength, successLength: successPluginsLength, errorLength: errorPluginsLength }) - if (isInstallingWithSuccess) - return t('plugin.task.installingWithSuccess', { installingLength: runningPluginsLength, successLength: successPluginsLength }) - if (isInstalling) - return t('plugin.task.installing') - if (isFailed) - return t('plugin.task.installedError', { errorLength: errorPluginsLength }) - if (isSuccess) - return t('plugin.task.installSuccess', { successLength: successPluginsLength }) - return t('plugin.task.installed') - }, [ - errorPluginsLength, - isFailed, - isInstalling, - isInstallingWithError, - isInstallingWithSuccess, - isSuccess, - runningPluginsLength, - successPluginsLength, - t, - ]) - - return { - open, - setOpen, - tip, - taskStatus, - handleClearAll, - handleClearErrors, - handleClearSingle, - } -}