import type { PluginStatus } from '@/app/components/plugins/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TaskStatus } from '@/app/components/plugins/types' // Import mocked modules import { useMutationClearTaskPlugin, usePluginTaskList } from '@/service/use-plugins' import PluginTaskList from './components/plugin-task-list' import TaskStatusIndicator from './components/task-status-indicator' import { usePluginTaskStatus } from './hooks' import PluginTasks from './index' // Mock external dependencies vi.mock('@/service/use-plugins', () => ({ usePluginTaskList: vi.fn(), useMutationClearTaskPlugin: vi.fn(), })) vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({ default: () => ({ getIconUrl: (icon: string) => `https://example.com/${icon}`, }), })) vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en_US', })) // Helper to create mock plugin const createMockPlugin = (overrides: Partial = {}): PluginStatus => ({ plugin_unique_identifier: `plugin-${Math.random().toString(36).substr(2, 9)}`, plugin_id: 'test-plugin', status: TaskStatus.running, message: '', icon: 'test-icon.png', labels: { en_US: 'Test Plugin', zh_Hans: '测试插件', } as Record, taskId: 'task-1', ...overrides, }) // Helper to setup mock hook returns const setupMocks = (plugins: PluginStatus[] = []) => { const mockMutateAsync = vi.fn().mockResolvedValue({}) const mockHandleRefetch = vi.fn() vi.mocked(usePluginTaskList).mockReturnValue({ pluginTasks: plugins.length > 0 ? [{ id: 'task-1', plugins, created_at: '', updated_at: '', status: 'running', total_plugins: plugins.length, completed_plugins: 0 }] : [], handleRefetch: mockHandleRefetch, } as any) vi.mocked(useMutationClearTaskPlugin).mockReturnValue({ mutateAsync: mockMutateAsync, } as any) return { mockMutateAsync, mockHandleRefetch } } // ============================================================================ // usePluginTaskStatus Hook Tests // ============================================================================ describe('usePluginTaskStatus Hook', () => { beforeEach(() => { vi.clearAllMocks() }) describe('Plugin categorization', () => { it('should categorize running plugins correctly', () => { const runningPlugin = createMockPlugin({ status: TaskStatus.running }) setupMocks([runningPlugin]) const TestComponent = () => { const { runningPlugins, runningPluginsLength } = usePluginTaskStatus() return (
{runningPluginsLength} {runningPlugins[0]?.plugin_unique_identifier}
) } render() expect(screen.getByTestId('running-count')).toHaveTextContent('1') expect(screen.getByTestId('running-id')).toHaveTextContent(runningPlugin.plugin_unique_identifier) }) it('should categorize success plugins correctly', () => { const successPlugin = createMockPlugin({ status: TaskStatus.success }) setupMocks([successPlugin]) const TestComponent = () => { const { successPlugins, successPluginsLength } = usePluginTaskStatus() return (
{successPluginsLength} {successPlugins[0]?.plugin_unique_identifier}
) } render() expect(screen.getByTestId('success-count')).toHaveTextContent('1') expect(screen.getByTestId('success-id')).toHaveTextContent(successPlugin.plugin_unique_identifier) }) it('should categorize error plugins correctly', () => { const errorPlugin = createMockPlugin({ status: TaskStatus.failed, message: 'Install failed' }) setupMocks([errorPlugin]) const TestComponent = () => { const { errorPlugins, errorPluginsLength } = usePluginTaskStatus() return (
{errorPluginsLength} {errorPlugins[0]?.plugin_unique_identifier}
) } render() expect(screen.getByTestId('error-count')).toHaveTextContent('1') expect(screen.getByTestId('error-id')).toHaveTextContent(errorPlugin.plugin_unique_identifier) }) it('should categorize mixed plugins correctly', () => { const plugins = [ createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }), createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }), createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }), ] setupMocks(plugins) const TestComponent = () => { const { runningPluginsLength, successPluginsLength, errorPluginsLength, totalPluginsLength } = usePluginTaskStatus() return (
{runningPluginsLength} {successPluginsLength} {errorPluginsLength} {totalPluginsLength}
) } render() expect(screen.getByTestId('running')).toHaveTextContent('1') expect(screen.getByTestId('success')).toHaveTextContent('1') expect(screen.getByTestId('error')).toHaveTextContent('1') expect(screen.getByTestId('total')).toHaveTextContent('3') }) }) describe('Status flags', () => { it('should set isInstalling when only running plugins exist', () => { setupMocks([createMockPlugin({ status: TaskStatus.running })]) const TestComponent = () => { const { isInstalling, isInstallingWithSuccess, isInstallingWithError, isSuccess, isFailed } = usePluginTaskStatus() return (
{String(isInstalling)} {String(isInstallingWithSuccess)} {String(isInstallingWithError)} {String(isSuccess)} {String(isFailed)}
) } render() expect(screen.getByTestId('isInstalling')).toHaveTextContent('true') expect(screen.getByTestId('isInstallingWithSuccess')).toHaveTextContent('false') expect(screen.getByTestId('isInstallingWithError')).toHaveTextContent('false') expect(screen.getByTestId('isSuccess')).toHaveTextContent('false') expect(screen.getByTestId('isFailed')).toHaveTextContent('false') }) it('should set isInstallingWithSuccess when running and success plugins exist', () => { setupMocks([ createMockPlugin({ status: TaskStatus.running }), createMockPlugin({ status: TaskStatus.success }), ]) const TestComponent = () => { const { isInstallingWithSuccess } = usePluginTaskStatus() return {String(isInstallingWithSuccess)} } render() expect(screen.getByTestId('flag')).toHaveTextContent('true') }) it('should set isInstallingWithError when running and error plugins exist', () => { setupMocks([ createMockPlugin({ status: TaskStatus.running }), createMockPlugin({ status: TaskStatus.failed }), ]) const TestComponent = () => { const { isInstallingWithError } = usePluginTaskStatus() return {String(isInstallingWithError)} } render() expect(screen.getByTestId('flag')).toHaveTextContent('true') }) it('should set isSuccess when all plugins succeeded', () => { setupMocks([ createMockPlugin({ status: TaskStatus.success }), createMockPlugin({ status: TaskStatus.success }), ]) const TestComponent = () => { const { isSuccess } = usePluginTaskStatus() return {String(isSuccess)} } render() expect(screen.getByTestId('flag')).toHaveTextContent('true') }) it('should set isFailed when no running plugins and some failed', () => { setupMocks([ createMockPlugin({ status: TaskStatus.success }), createMockPlugin({ status: TaskStatus.failed }), ]) const TestComponent = () => { const { isFailed } = usePluginTaskStatus() return {String(isFailed)} } render() expect(screen.getByTestId('flag')).toHaveTextContent('true') }) }) describe('handleClearErrorPlugin', () => { it('should call mutateAsync and handleRefetch', async () => { const { mockMutateAsync, mockHandleRefetch } = setupMocks([ createMockPlugin({ status: TaskStatus.failed }), ]) const TestComponent = () => { const { handleClearErrorPlugin } = usePluginTaskStatus() return ( ) } render() fireEvent.click(screen.getByRole('button')) await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith({ taskId: 'task-1', pluginId: 'plugin-1', }) expect(mockHandleRefetch).toHaveBeenCalled() }) }) }) }) // ============================================================================ // TaskStatusIndicator Component Tests // ============================================================================ describe('TaskStatusIndicator Component', () => { const defaultProps = { tip: 'Test tooltip', isInstalling: false, isInstallingWithSuccess: false, isInstallingWithError: false, isSuccess: false, isFailed: false, successPluginsLength: 0, runningPluginsLength: 0, totalPluginsLength: 1, onClick: vi.fn(), } beforeEach(() => { vi.clearAllMocks() }) describe('Rendering', () => { it('should render without crashing', () => { render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should render with correct id', () => { render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) }) describe('Icon display', () => { it('should show downloading icon when installing', () => { render() // DownloadingIcon is rendered when isInstalling is true expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should show downloading icon when installing with error', () => { render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should show install icon when not installing', () => { render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) }) describe('Status badge', () => { it('should show progress circle when installing', () => { render( , ) expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should show progress circle when installing with success', () => { render( , ) expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should show error progress circle when installing with error', () => { render( , ) expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should show success icon when all completed successfully', () => { render( , ) expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should show error icon when failed', () => { render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) }) describe('Styling', () => { it('should apply error styles when installing with error', () => { render() const trigger = document.getElementById('plugin-task-trigger') expect(trigger).toHaveClass('bg-state-destructive-hover') }) it('should apply error styles when failed', () => { render() const trigger = document.getElementById('plugin-task-trigger') expect(trigger).toHaveClass('bg-state-destructive-hover') }) it('should apply cursor-pointer when clickable', () => { render() const trigger = document.getElementById('plugin-task-trigger') expect(trigger).toHaveClass('cursor-pointer') }) }) describe('User interactions', () => { it('should call onClick when clicked', () => { const handleClick = vi.fn() render() fireEvent.click(document.getElementById('plugin-task-trigger')!) expect(handleClick).toHaveBeenCalledTimes(1) }) }) }) // ============================================================================ // PluginTaskList Component Tests // ============================================================================ describe('PluginTaskList Component', () => { const defaultProps = { runningPlugins: [] as PluginStatus[], successPlugins: [] as PluginStatus[], errorPlugins: [] as PluginStatus[], getIconUrl: (icon: string) => `https://example.com/${icon}`, onClearAll: vi.fn(), onClearErrors: vi.fn(), onClearSingle: vi.fn(), } beforeEach(() => { vi.clearAllMocks() }) describe('Rendering', () => { it('should render without crashing with empty lists', () => { render() expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() }) it('should render running plugins section when plugins exist', () => { const runningPlugins = [createMockPlugin({ status: TaskStatus.running })] render() // Translation key is returned as text in tests, multiple matches expected (title + status) expect(screen.getAllByText(/task\.installing/i).length).toBeGreaterThan(0) // Verify section container is rendered expect(document.querySelector('.max-h-\\[200px\\]')).toBeInTheDocument() }) it('should render success plugins section when plugins exist', () => { const successPlugins = [createMockPlugin({ status: TaskStatus.success })] render() // Translation key is returned as text in tests, multiple matches expected expect(screen.getAllByText(/task\.installed/i).length).toBeGreaterThan(0) }) it('should render error plugins section when plugins exist', () => { const errorPlugins = [createMockPlugin({ status: TaskStatus.failed, message: 'Error occurred' })] render() expect(screen.getByText('Error occurred')).toBeInTheDocument() }) it('should render all sections when all types exist', () => { render( , ) // All sections should be present expect(document.querySelectorAll('.max-h-\\[200px\\]').length).toBe(3) }) }) describe('User interactions', () => { it('should call onClearAll when clear all button is clicked in success section', () => { const handleClearAll = vi.fn() const successPlugins = [createMockPlugin({ status: TaskStatus.success })] render( , ) fireEvent.click(screen.getByRole('button', { name: /task\.clearAll/i })) expect(handleClearAll).toHaveBeenCalledTimes(1) }) it('should call onClearErrors when clear all button is clicked in error section', () => { const handleClearErrors = vi.fn() const errorPlugins = [createMockPlugin({ status: TaskStatus.failed })] render( , ) const clearButtons = screen.getAllByRole('button') fireEvent.click(clearButtons.find(btn => btn.textContent?.includes('task.clearAll'))!) expect(handleClearErrors).toHaveBeenCalledTimes(1) }) it('should call onClearSingle with correct args when individual clear is clicked', () => { const handleClearSingle = vi.fn() const errorPlugin = createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-plugin-1', taskId: 'task-123', }) render( , ) // The individual clear button has the text 'operation.clear' fireEvent.click(screen.getByRole('button', { name: /operation\.clear/i })) expect(handleClearSingle).toHaveBeenCalledWith('task-123', 'error-plugin-1') }) }) describe('Plugin display', () => { it('should display plugin name from labels', () => { const plugin = createMockPlugin({ status: TaskStatus.running, labels: { en_US: 'My Test Plugin' } as Record, }) render() expect(screen.getByText('My Test Plugin')).toBeInTheDocument() }) it('should display plugin message when available', () => { const plugin = createMockPlugin({ status: TaskStatus.success, message: 'Successfully installed!', }) render() expect(screen.getByText('Successfully installed!')).toBeInTheDocument() }) it('should display multiple plugins in each section', () => { const runningPlugins = [ createMockPlugin({ status: TaskStatus.running, labels: { en_US: 'Plugin A' } as Record }), createMockPlugin({ status: TaskStatus.running, labels: { en_US: 'Plugin B' } as Record }), ] render() expect(screen.getByText('Plugin A')).toBeInTheDocument() expect(screen.getByText('Plugin B')).toBeInTheDocument() // Count is rendered, verify multiple items are in list expect(document.querySelectorAll('.hover\\:bg-state-base-hover').length).toBe(2) }) }) }) // ============================================================================ // PluginTasks Main Component Tests // ============================================================================ describe('PluginTasks Component', () => { beforeEach(() => { vi.clearAllMocks() }) describe('Rendering', () => { it('should return null when no plugins exist', () => { setupMocks([]) const { container } = render() expect(container.firstChild).toBeNull() }) it('should render when plugins exist', () => { setupMocks([createMockPlugin({ status: TaskStatus.running })]) render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) }) describe('Tooltip text (tip memoization)', () => { it('should show installing tip when isInstalling', () => { setupMocks([createMockPlugin({ status: TaskStatus.running })]) render() // The component renders with a tooltip, we verify it exists expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should show success tip when all succeeded', () => { setupMocks([createMockPlugin({ status: TaskStatus.success })]) render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should show error tip when some failed', () => { setupMocks([ createMockPlugin({ status: TaskStatus.success }), createMockPlugin({ status: TaskStatus.failed }), ]) render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) }) describe('Popover interaction', () => { it('should toggle popover when trigger is clicked and status allows', () => { setupMocks([createMockPlugin({ status: TaskStatus.running })]) render() // Click to open fireEvent.click(document.getElementById('plugin-task-trigger')!) // The popover content should be visible (PluginTaskList) expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() }) it('should not toggle when status does not allow', () => { // Setup with no actionable status (edge case - should not happen in practice) setupMocks([createMockPlugin({ status: TaskStatus.running })]) render() // Component should still render expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) }) describe('Clear handlers', () => { it('should clear all completed plugins when onClearAll is called', async () => { const { mockMutateAsync } = setupMocks([ createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }), createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }), ]) render() // Open popover fireEvent.click(document.getElementById('plugin-task-trigger')!) // Wait for popover content to render await waitFor(() => { expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() }) // Find and click clear all button const clearButtons = screen.getAllByRole('button') const clearAllButton = clearButtons.find(btn => btn.textContent?.includes('clearAll')) if (clearAllButton) fireEvent.click(clearAllButton) // Verify mutateAsync was called for each completed plugin await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalled() }) }) it('should clear only error plugins when onClearErrors is called', async () => { const { mockMutateAsync } = setupMocks([ createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }), ]) render() // Open popover fireEvent.click(document.getElementById('plugin-task-trigger')!) await waitFor(() => { expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() }) // Find and click the clear all button in error section const clearButtons = screen.getAllByRole('button') if (clearButtons.length > 0) fireEvent.click(clearButtons[0]) await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalled() }) }) it('should clear single plugin when onClearSingle is called', async () => { const { mockMutateAsync } = setupMocks([ createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-plugin', taskId: 'task-1', }), ]) render() // Open popover fireEvent.click(document.getElementById('plugin-task-trigger')!) await waitFor(() => { expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() }) // Find and click individual clear button (usually the last one) const clearButtons = screen.getAllByRole('button') const individualClearButton = clearButtons[clearButtons.length - 1] fireEvent.click(individualClearButton) await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith({ taskId: 'task-1', pluginId: 'error-plugin', }) }) }) }) describe('Edge cases', () => { it('should handle empty plugin tasks array', () => { setupMocks([]) const { container } = render() expect(container.firstChild).toBeNull() }) it('should handle single running plugin', () => { setupMocks([createMockPlugin({ status: TaskStatus.running })]) render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should handle many plugins', () => { const manyPlugins = Array.from({ length: 10 }, (_, i) => createMockPlugin({ status: i % 3 === 0 ? TaskStatus.running : i % 3 === 1 ? TaskStatus.success : TaskStatus.failed, plugin_unique_identifier: `plugin-${i}`, })) setupMocks(manyPlugins) render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should handle plugins with empty labels', () => { const plugin = createMockPlugin({ status: TaskStatus.running, labels: {} as Record, }) setupMocks([plugin]) render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should handle plugins with long messages', () => { const plugin = createMockPlugin({ status: TaskStatus.failed, message: 'A'.repeat(500), }) setupMocks([plugin]) render() // Open popover fireEvent.click(document.getElementById('plugin-task-trigger')!) expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument() }) }) }) // ============================================================================ // Integration Tests // ============================================================================ describe('PluginTasks Integration', () => { beforeEach(() => { vi.clearAllMocks() }) it('should show correct UI flow from installing to success', async () => { // Start with installing state setupMocks([createMockPlugin({ status: TaskStatus.running })]) const { rerender } = render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() // Simulate completion by re-rendering with success setupMocks([createMockPlugin({ status: TaskStatus.success })]) rerender() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should show correct UI flow from installing to failure', async () => { // Start with installing state setupMocks([createMockPlugin({ status: TaskStatus.running })]) const { rerender } = render() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() // Simulate failure by re-rendering with failed setupMocks([createMockPlugin({ status: TaskStatus.failed, message: 'Network error' })]) rerender() expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() }) it('should handle mixed status during installation', () => { setupMocks([ createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'p1' }), createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'p2' }), createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'p3' }), ]) render() // Open popover fireEvent.click(document.getElementById('plugin-task-trigger')!) // All sections should be visible const sections = document.querySelectorAll('.max-h-\\[200px\\]') expect(sections.length).toBe(3) }) })