diff --git a/web/app/components/plugins/card/index.spec.tsx b/web/app/components/plugins/card/index.spec.tsx index fd97534ec4..8dd7e67d69 100644 --- a/web/app/components/plugins/card/index.spec.tsx +++ b/web/app/components/plugins/card/index.spec.tsx @@ -897,6 +897,58 @@ describe('Icon', () => { const iconDiv = container.firstChild as HTMLElement expect(iconDiv).toHaveStyle({ backgroundImage: 'url(/icon?name=test&size=large)' }) }) + + it('should not render status indicators when src is object with installed=true', () => { + render() + + // Status indicators should not render for object src + expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument() + }) + + it('should not render status indicators when src is object with installFailed=true', () => { + render() + + // Status indicators should not render for object src + expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument() + }) + + it('should render object src with all size variants', () => { + const sizes: Array<'xs' | 'tiny' | 'small' | 'medium' | 'large'> = ['xs', 'tiny', 'small', 'medium', 'large'] + + sizes.forEach((size) => { + const { unmount } = render() + expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size) + unmount() + }) + }) + + it('should render object src with custom className', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.custom-object-icon')).toBeInTheDocument() + }) + + it('should pass correct props to AppIcon for object src', () => { + render() + + const appIcon = screen.getByTestId('app-icon') + expect(appIcon).toHaveAttribute('data-icon', '๐Ÿ˜€') + expect(appIcon).toHaveAttribute('data-background', '#123456') + expect(appIcon).toHaveAttribute('data-icon-type', 'emoji') + }) + + it('should render inner icon only when shouldUseMcpIcon returns true', () => { + // Test with MCP icon content + const { unmount } = render() + expect(screen.getByTestId('inner-icon')).toBeInTheDocument() + unmount() + + // Test without MCP icon content + render() + expect(screen.queryByTestId('inner-icon')).not.toBeInTheDocument() + }) }) }) diff --git a/web/app/components/plugins/plugin-page/context.spec.tsx b/web/app/components/plugins/plugin-page/context.spec.tsx new file mode 100644 index 0000000000..ea52ae1dbd --- /dev/null +++ b/web/app/components/plugins/plugin-page/context.spec.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +// Import mocks +import { useGlobalPublicStore } from '@/context/global-public-context' + +import { PluginPageContext, PluginPageContextProvider, usePluginPageContext } from './context' + +// Mock dependencies +vi.mock('nuqs', () => ({ + useQueryState: vi.fn(() => ['plugins', vi.fn()]), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: vi.fn(), +})) + +vi.mock('../hooks', () => ({ + PLUGIN_PAGE_TABS_MAP: { + plugins: 'plugins', + marketplace: 'discover', + }, + usePluginPageTabs: () => [ + { value: 'plugins', text: 'Plugins' }, + { value: 'discover', text: 'Explore Marketplace' }, + ], +})) + +// Helper function to mock useGlobalPublicStore with marketplace setting +const mockGlobalPublicStore = (enableMarketplace: boolean) => { + vi.mocked(useGlobalPublicStore).mockImplementation((selector) => { + const state = { systemFeatures: { enable_marketplace: enableMarketplace } } + return selector(state as Parameters[0]) + }) +} + +// Test component that uses the context +const TestConsumer = () => { + const containerRef = usePluginPageContext(v => v.containerRef) + const options = usePluginPageContext(v => v.options) + const activeTab = usePluginPageContext(v => v.activeTab) + + return ( +
+ {containerRef ? 'true' : 'false'} + {options.length} + {activeTab} + {options.map((opt: { value: string, text: string }) => ( + {opt.text} + ))} +
+ ) +} + +describe('PluginPageContext', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('PluginPageContextProvider', () => { + it('should provide context values to children', () => { + mockGlobalPublicStore(true) + + render( + + + , + ) + + expect(screen.getByTestId('has-container-ref')).toHaveTextContent('true') + expect(screen.getByTestId('options-count')).toHaveTextContent('2') + }) + + it('should include marketplace tab when enable_marketplace is true', () => { + mockGlobalPublicStore(true) + + render( + + + , + ) + + expect(screen.getByTestId('option-plugins')).toBeInTheDocument() + expect(screen.getByTestId('option-discover')).toBeInTheDocument() + }) + + it('should filter out marketplace tab when enable_marketplace is false', () => { + mockGlobalPublicStore(false) + + render( + + + , + ) + + expect(screen.getByTestId('option-plugins')).toBeInTheDocument() + expect(screen.queryByTestId('option-discover')).not.toBeInTheDocument() + expect(screen.getByTestId('options-count')).toHaveTextContent('1') + }) + }) + + describe('usePluginPageContext', () => { + it('should select specific context values', () => { + mockGlobalPublicStore(true) + + render( + + + , + ) + + // activeTab should be 'plugins' from the mock + expect(screen.getByTestId('active-tab')).toHaveTextContent('plugins') + }) + }) + + describe('Default Context Values', () => { + it('should have empty options by default from context', () => { + // Test that the context has proper default values by checking the exported constant + // The PluginPageContext is created with default values including empty options array + expect(PluginPageContext).toBeDefined() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/index.spec.tsx b/web/app/components/plugins/plugin-page/index.spec.tsx new file mode 100644 index 0000000000..a3ea7f7125 --- /dev/null +++ b/web/app/components/plugins/plugin-page/index.spec.tsx @@ -0,0 +1,1041 @@ +import type { PluginPageProps } from './index' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useQueryState } from 'nuqs' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePluginInstallation } from '@/hooks/use-query-params' +// Import mocked modules for assertions +import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' +import PluginPageWithContext from './index' + +// Mock external dependencies +vi.mock('@/service/plugins', () => ({ + fetchManifestFromMarketPlace: vi.fn(), + fetchBundleInfoFromMarketPlace: vi.fn(), +})) + +vi.mock('@/hooks/use-query-params', () => ({ + usePluginInstallation: vi.fn(() => [{ packageId: null, bundleInfo: null }, vi.fn()]), +})) + +vi.mock('@/hooks/use-document-title', () => ({ + default: vi.fn(), +})) + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: vi.fn((selector) => { + const state = { + systemFeatures: { + enable_marketplace: true, + }, + } + return selector(state) + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: false, + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useReferenceSettings: () => ({ + data: { + permission: { + install_permission: 'everyone', + debug_permission: 'admins', + }, + }, + }), + useMutationReferenceSettings: () => ({ + mutate: vi.fn(), + isPending: false, + }), + useInvalidateReferenceSettings: () => vi.fn(), + usePluginTaskList: () => ({ + pluginTasks: [], + handleRefetch: vi.fn(), + }), + useMutationClearTaskPlugin: () => ({ + mutateAsync: vi.fn(), + }), + useInstalledPluginList: () => ({ + data: [], + isLoading: false, + isFetching: false, + isLastPage: true, + loadNextPage: vi.fn(), + }), + useInstalledLatestVersion: () => ({ + data: {}, + }), + useInvalidateInstalledPluginList: () => vi.fn(), +})) + +vi.mock('nuqs', () => ({ + useQueryState: vi.fn(() => ['plugins', vi.fn()]), +})) + +vi.mock('./plugin-tasks', () => ({ + default: () =>
PluginTasks
, +})) + +vi.mock('./debug-info', () => ({ + default: () =>
DebugInfo
, +})) + +vi.mock('./install-plugin-dropdown', () => ({ + default: ({ onSwitchToMarketplaceTab }: { onSwitchToMarketplaceTab: () => void }) => ( + + ), +})) + +vi.mock('../install-plugin/install-from-local-package', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('../install-plugin/install-from-marketplace', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/plugins/reference-setting-modal', () => ({ + default: ({ onHide }: { onHide: () => void }) => ( +
+ +
+ ), +})) + +// Helper to create default props +const createDefaultProps = (): PluginPageProps => ({ + plugins:
Plugins Content
, + marketplace:
Marketplace Content
, +}) + +// ============================================================================ +// PluginPage Component Tests +// ============================================================================ +describe('PluginPage Component', () => { + beforeEach(() => { + vi.clearAllMocks() + // Reset to default mock values + vi.mocked(usePluginInstallation).mockReturnValue([ + { packageId: null, bundleInfo: null }, + vi.fn(), + ]) + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + }) + + // ============================================================================ + // Rendering Tests + // ============================================================================ + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(document.getElementById('marketplace-container')).toBeInTheDocument() + }) + + it('should render with correct container id', () => { + render() + const container = document.getElementById('marketplace-container') + expect(container).toBeInTheDocument() + }) + + it('should render PluginTasks component', () => { + render() + expect(screen.getByTestId('plugin-tasks')).toBeInTheDocument() + }) + + it('should render plugins content when on plugins tab', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + expect(screen.getByTestId('plugins-content')).toBeInTheDocument() + }) + + it('should render marketplace content when on marketplace tab', () => { + vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) + + render() + // The marketplace content should be visible when enable_marketplace is true and on discover tab + const container = document.getElementById('marketplace-container') + expect(container).toBeInTheDocument() + // Check that marketplace-specific links are shown + expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument() + }) + + it('should render TabSlider', () => { + render() + // TabSlider renders tab options + expect(document.querySelector('.flex-1')).toBeInTheDocument() + }) + + it('should render drag and drop hint when on plugins tab', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + expect(screen.getByText(/dropPluginToInstall/i)).toBeInTheDocument() + }) + + it('should render file input for plugin upload', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + const fileInput = document.getElementById('fileUploader') + expect(fileInput).toBeInTheDocument() + expect(fileInput).toHaveAttribute('type', 'file') + }) + }) + + // ============================================================================ + // Tab Navigation Tests + // ============================================================================ + describe('Tab Navigation', () => { + it('should display plugins tab as active by default', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + expect(screen.getByTestId('plugins-content')).toBeInTheDocument() + }) + + it('should show marketplace links when on marketplace tab', () => { + vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) + + render() + // Check for marketplace-specific buttons + expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument() + expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument() + }) + + it('should not show marketplace links when on plugins tab', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + expect(screen.queryByText(/requestAPlugin/i)).not.toBeInTheDocument() + }) + }) + + // ============================================================================ + // Permission-based Rendering Tests + // ============================================================================ + describe('Permission-based Rendering', () => { + it('should render InstallPluginDropdown when canManagement is true', () => { + render() + expect(screen.getByTestId('install-dropdown')).toBeInTheDocument() + }) + + it('should render DebugInfo when canDebugger is true', () => { + render() + expect(screen.getByTestId('debug-info')).toBeInTheDocument() + }) + + it('should render settings button when canSetPermissions is true', () => { + render() + // Settings button with RiEqualizer2Line icon + const settingsButtons = document.querySelectorAll('button') + expect(settingsButtons.length).toBeGreaterThan(0) + }) + + it('should call setActiveTab when onSwitchToMarketplaceTab is called', async () => { + const mockSetActiveTab = vi.fn() + vi.mocked(useQueryState).mockReturnValue(['plugins', mockSetActiveTab]) + + render() + + // Click the install dropdown button which triggers onSwitchToMarketplaceTab + fireEvent.click(screen.getByTestId('install-dropdown')) + + // The mock onSwitchToMarketplaceTab calls setActiveTab('discover') + // Since our mock InstallPluginDropdown calls onSwitchToMarketplaceTab on click + // we verify that setActiveTab was called with 'discover'. + expect(mockSetActiveTab).toHaveBeenCalledWith('discover') + }) + + it('should use noop for file handlers when canManagement is false', () => { + // Override mock to disable management permission + vi.doMock('@/service/use-plugins', () => ({ + useReferenceSettings: () => ({ + data: { + permission: { + install_permission: 'noone', + debug_permission: 'noone', + }, + }, + }), + useMutationReferenceSettings: () => ({ + mutate: vi.fn(), + isPending: false, + }), + useInvalidateReferenceSettings: () => vi.fn(), + usePluginTaskList: () => ({ + pluginTasks: [], + handleRefetch: vi.fn(), + }), + useMutationClearTaskPlugin: () => ({ + mutateAsync: vi.fn(), + }), + useInstalledPluginList: () => ({ + data: [], + isLoading: false, + isFetching: false, + isLastPage: true, + loadNextPage: vi.fn(), + }), + useInstalledLatestVersion: () => ({ + data: {}, + }), + useInvalidateInstalledPluginList: () => vi.fn(), + })) + + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + + // File input should still be in the document (even if handlers are noop) + const fileInput = document.getElementById('fileUploader') + expect(fileInput).toBeInTheDocument() + }) + }) + + // ============================================================================ + // File Upload Tests + // ============================================================================ + describe('File Upload', () => { + it('should have hidden file input', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + const fileInput = document.getElementById('fileUploader') as HTMLInputElement + expect(fileInput).toHaveClass('hidden') + }) + + it('should accept .difypkg files', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + const fileInput = document.getElementById('fileUploader') as HTMLInputElement + expect(fileInput.accept).toContain('.difypkg') + }) + + it('should show InstallFromLocalPackage modal when valid file is selected', async () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + const fileInput = document.getElementById('fileUploader') as HTMLInputElement + + const file = new File(['content'], 'plugin.difypkg', { type: 'application/octet-stream' }) + Object.defineProperty(fileInput, 'files', { + value: [file], + }) + + fireEvent.change(fileInput) + + await waitFor(() => { + expect(screen.getByTestId('install-local-modal')).toBeInTheDocument() + }) + }) + + it('should not show modal for non-.difypkg files', async () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + const fileInput = document.getElementById('fileUploader') as HTMLInputElement + + const file = new File(['content'], 'plugin.txt', { type: 'text/plain' }) + Object.defineProperty(fileInput, 'files', { + value: [file], + }) + + fireEvent.change(fileInput) + + await waitFor(() => { + expect(screen.queryByTestId('install-local-modal')).not.toBeInTheDocument() + }) + }) + }) + + // ============================================================================ + // Marketplace Installation Tests + // ============================================================================ + describe('Marketplace Installation', () => { + it('should fetch manifest when packageId is provided', async () => { + const mockSetInstallState = vi.fn() + vi.mocked(usePluginInstallation).mockReturnValue([ + { packageId: 'test-package-id', bundleInfo: null }, + mockSetInstallState, + ]) + + vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({ + data: { + plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' }, + version: { version: '1.0.0' }, + }, + } as Awaited>) + + render() + + await waitFor(() => { + expect(fetchManifestFromMarketPlace).toHaveBeenCalledWith('test-package-id') + }) + }) + + it('should fetch bundle info when bundleInfo is provided', async () => { + const mockSetInstallState = vi.fn() + vi.mocked(usePluginInstallation).mockReturnValue([ + { packageId: null, bundleInfo: 'test-bundle-info' as unknown }, + mockSetInstallState, + ] as ReturnType) + + vi.mocked(fetchBundleInfoFromMarketPlace).mockResolvedValue({ + data: { version: { dependencies: [] } }, + } as unknown as Awaited>) + + render() + + await waitFor(() => { + expect(fetchBundleInfoFromMarketPlace).toHaveBeenCalledWith('test-bundle-info') + }) + }) + + it('should show InstallFromMarketplace modal after fetching manifest', async () => { + const mockSetInstallState = vi.fn() + vi.mocked(usePluginInstallation).mockReturnValue([ + { packageId: 'test-package-id', bundleInfo: null }, + mockSetInstallState, + ]) + + vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({ + data: { + plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' }, + version: { version: '1.0.0' }, + }, + } as Awaited>) + + render() + + await waitFor(() => { + expect(screen.getByTestId('install-marketplace-modal')).toBeInTheDocument() + }, { timeout: 3000 }) + }) + + it('should handle fetch error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + vi.mocked(usePluginInstallation).mockReturnValue([ + { packageId: null, bundleInfo: 'invalid-bundle' as unknown }, + vi.fn(), + ] as ReturnType) + + vi.mocked(fetchBundleInfoFromMarketPlace).mockRejectedValue(new Error('Network error')) + + render() + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Failed to load bundle info:', expect.any(Error)) + }) + + consoleSpy.mockRestore() + }) + }) + + // ============================================================================ + // Settings Modal Tests + // ============================================================================ + describe('Settings Modal', () => { + it('should open settings modal when settings button is clicked', async () => { + render() + + fireEvent.click(screen.getByTestId('plugin-settings-button')) + + await waitFor(() => { + expect(screen.getByTestId('reference-setting-modal')).toBeInTheDocument() + }) + }) + + it('should close settings modal when onHide is called', async () => { + render() + + // Open modal + fireEvent.click(screen.getByTestId('plugin-settings-button')) + + await waitFor(() => { + expect(screen.getByTestId('reference-setting-modal')).toBeInTheDocument() + }) + + // Close modal + fireEvent.click(screen.getByText('Close Settings')) + + await waitFor(() => { + expect(screen.queryByTestId('reference-setting-modal')).not.toBeInTheDocument() + }) + }) + }) + + // ============================================================================ + // Drag and Drop Tests + // ============================================================================ + describe('Drag and Drop', () => { + it('should show dragging overlay when dragging files over container', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + const container = document.getElementById('marketplace-container')! + + // Simulate drag enter + const dragEnterEvent = new Event('dragenter', { bubbles: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'] }, + }) + container.dispatchEvent(dragEnterEvent) + + // Check for dragging overlay styles + expect(container).toBeInTheDocument() + }) + + it('should highlight drop zone text when dragging', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + + // The drag hint should be visible + const dragHint = screen.getByText(/dropPluginToInstall/i) + expect(dragHint).toBeInTheDocument() + }) + }) + + // ============================================================================ + // Memoization Tests + // ============================================================================ + describe('Memoization', () => { + it('should memoize isPluginsTab correctly', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + const { rerender } = render() + + // Should show plugins content + expect(screen.getByTestId('plugins-content')).toBeInTheDocument() + + // Rerender with same props - memoized value should be same + rerender() + expect(screen.getByTestId('plugins-content')).toBeInTheDocument() + }) + + it('should memoize isExploringMarketplace correctly', () => { + vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) + + const { rerender } = render() + + // Should show marketplace links when on discover tab + expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument() + + // Rerender with same props + rerender() + expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument() + }) + + it('should recognize plugin type tabs as marketplace', () => { + // Test with a plugin type tab like 'tool' + vi.mocked(useQueryState).mockReturnValue(['tool', vi.fn()]) + + render() + + // Should show marketplace links when on a plugin type tab + expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument() + expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument() + }) + + it('should render marketplace content when isExploringMarketplace and enable_marketplace are true', () => { + vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) + + render() + + // The marketplace prop content should be rendered + // Since we mock the marketplace as a div, check it's not hidden + const container = document.getElementById('marketplace-container') + expect(container).toBeInTheDocument() + expect(container).toHaveClass('bg-background-body') + }) + }) + + // ============================================================================ + // Context Provider Tests + // ============================================================================ + describe('Context Provider', () => { + it('should wrap component with PluginPageContextProvider', () => { + render() + + // The component should render, indicating context is working + expect(document.getElementById('marketplace-container')).toBeInTheDocument() + }) + + it('should filter out marketplace tab when enable_marketplace is false', () => { + // This tests line 69 in context.tsx - the false branch of enable_marketplace + // The marketplace tab should be filtered out from options + render() + // Component should still work without marketplace + expect(document.getElementById('marketplace-container')).toBeInTheDocument() + }) + }) + + // ============================================================================ + // Edge Cases and Error Handling + // ============================================================================ + describe('Edge Cases', () => { + it('should handle null plugins prop', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + expect(document.getElementById('marketplace-container')).toBeInTheDocument() + }) + + it('should handle empty marketplace prop', () => { + vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) + + render() + expect(document.getElementById('marketplace-container')).toBeInTheDocument() + }) + + it('should handle rapid tab switches', async () => { + const mockSetActiveTab = vi.fn() + vi.mocked(useQueryState).mockReturnValue(['plugins', mockSetActiveTab]) + + render() + + // Simulate rapid switches by updating state + act(() => { + vi.mocked(useQueryState).mockReturnValue(['discover', mockSetActiveTab]) + }) + + expect(document.getElementById('marketplace-container')).toBeInTheDocument() + }) + + it('should handle marketplace disabled', () => { + // Mock marketplace disabled + vi.mock('@/context/global-public-context', async () => ({ + useGlobalPublicStore: vi.fn((selector) => { + const state = { + systemFeatures: { + enable_marketplace: false, + }, + } + return selector(state) + }), + })) + + vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) + + render() + + // Component should still render but without marketplace content when disabled + expect(document.getElementById('marketplace-container')).toBeInTheDocument() + }) + + it('should handle file with empty name', async () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + const fileInput = document.getElementById('fileUploader') as HTMLInputElement + + const file = new File(['content'], '', { type: 'application/octet-stream' }) + Object.defineProperty(fileInput, 'files', { + value: [file], + }) + + fireEvent.change(fileInput) + + // Should not show modal for file without proper extension + await waitFor(() => { + expect(screen.queryByTestId('install-local-modal')).not.toBeInTheDocument() + }) + }) + + it('should handle no files selected', async () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + const fileInput = document.getElementById('fileUploader') as HTMLInputElement + + Object.defineProperty(fileInput, 'files', { + value: [], + }) + + fireEvent.change(fileInput) + + // Should not show modal + expect(screen.queryByTestId('install-local-modal')).not.toBeInTheDocument() + }) + }) + + // ============================================================================ + // Cleanup Tests + // ============================================================================ + describe('Cleanup', () => { + it('should reset install state when hiding marketplace modal', async () => { + const mockSetInstallState = vi.fn() + vi.mocked(usePluginInstallation).mockReturnValue([ + { packageId: 'test-package', bundleInfo: null }, + mockSetInstallState, + ]) + + vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({ + data: { + plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' }, + version: { version: '1.0.0' }, + }, + } as Awaited>) + + render() + + // Wait for modal to appear + await waitFor(() => { + expect(screen.getByTestId('install-marketplace-modal')).toBeInTheDocument() + }, { timeout: 3000 }) + + // Close modal + fireEvent.click(screen.getByText('Close')) + + await waitFor(() => { + expect(mockSetInstallState).toHaveBeenCalledWith(null) + }) + }) + }) + + // ============================================================================ + // Styling Tests + // ============================================================================ + describe('Styling', () => { + it('should apply correct background for plugins tab', () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + const container = document.getElementById('marketplace-container') + + expect(container).toHaveClass('bg-components-panel-bg') + }) + + it('should apply correct background for marketplace tab', () => { + vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) + + render() + const container = document.getElementById('marketplace-container') + + expect(container).toHaveClass('bg-background-body') + }) + + it('should have scrollbar-gutter stable style', () => { + render() + const container = document.getElementById('marketplace-container') + + expect(container).toHaveStyle({ scrollbarGutter: 'stable' }) + }) + }) +}) + +// ============================================================================ +// Uploader Hook Integration Tests +// ============================================================================ +describe('Uploader Hook Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + }) + + describe('Drag Events', () => { + it('should handle dragover event', async () => { + render() + const container = document.getElementById('marketplace-container')! + + const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true }) + Object.defineProperty(dragOverEvent, 'dataTransfer', { + value: { types: ['Files'] }, + }) + + act(() => { + container.dispatchEvent(dragOverEvent) + }) + + expect(container).toBeInTheDocument() + }) + + it('should handle dragleave event when leaving container', async () => { + render() + const container = document.getElementById('marketplace-container')! + + const dragEnterEvent = new Event('dragenter', { bubbles: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'] }, + }) + act(() => { + container.dispatchEvent(dragEnterEvent) + }) + + const dragLeaveEvent = new Event('dragleave', { bubbles: true }) + Object.defineProperty(dragLeaveEvent, 'relatedTarget', { + value: null, + }) + act(() => { + container.dispatchEvent(dragLeaveEvent) + }) + + expect(container).toBeInTheDocument() + }) + + it('should handle dragleave event when moving to element outside container', async () => { + render() + const container = document.getElementById('marketplace-container')! + + const dragEnterEvent = new Event('dragenter', { bubbles: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'] }, + }) + act(() => { + container.dispatchEvent(dragEnterEvent) + }) + + const outsideElement = document.createElement('div') + document.body.appendChild(outsideElement) + + const dragLeaveEvent = new Event('dragleave', { bubbles: true }) + Object.defineProperty(dragLeaveEvent, 'relatedTarget', { + value: outsideElement, + }) + act(() => { + container.dispatchEvent(dragLeaveEvent) + }) + + expect(container).toBeInTheDocument() + document.body.removeChild(outsideElement) + }) + + it('should handle drop event with files', async () => { + render() + const container = document.getElementById('marketplace-container')! + + const dragEnterEvent = new Event('dragenter', { bubbles: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'] }, + }) + act(() => { + container.dispatchEvent(dragEnterEvent) + }) + + const file = new File(['content'], 'test-plugin.difypkg', { type: 'application/octet-stream' }) + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [file] }, + }) + + act(() => { + container.dispatchEvent(dropEvent) + }) + + await waitFor(() => { + expect(screen.getByTestId('install-local-modal')).toBeInTheDocument() + }) + }) + + it('should handle drop event without dataTransfer', async () => { + render() + const container = document.getElementById('marketplace-container')! + + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + + act(() => { + container.dispatchEvent(dropEvent) + }) + + expect(screen.queryByTestId('install-local-modal')).not.toBeInTheDocument() + }) + + it('should handle drop event with empty files array', async () => { + render() + const container = document.getElementById('marketplace-container')! + + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [] }, + }) + + act(() => { + container.dispatchEvent(dropEvent) + }) + + expect(screen.queryByTestId('install-local-modal')).not.toBeInTheDocument() + }) + }) + + describe('File Change Handler', () => { + it('should handle file change with null file', async () => { + render() + const fileInput = document.getElementById('fileUploader') as HTMLInputElement + + Object.defineProperty(fileInput, 'files', { value: null }) + + fireEvent.change(fileInput) + + expect(screen.queryByTestId('install-local-modal')).not.toBeInTheDocument() + }) + }) + + describe('Remove File', () => { + it('should clear file input when removeFile is called', async () => { + render() + const fileInput = document.getElementById('fileUploader') as HTMLInputElement + + const file = new File(['content'], 'plugin.difypkg', { type: 'application/octet-stream' }) + Object.defineProperty(fileInput, 'files', { value: [file] }) + fireEvent.change(fileInput) + + await waitFor(() => { + expect(screen.getByTestId('install-local-modal')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Close')) + + await waitFor(() => { + expect(screen.queryByTestId('install-local-modal')).not.toBeInTheDocument() + }) + }) + }) +}) + +// ============================================================================ +// Reference Setting Hook Integration Tests +// ============================================================================ +describe('Reference Setting Hook Integration', () => { + describe('Permission Handling', () => { + it('should render InstallPluginDropdown when permission is everyone', () => { + render() + expect(screen.getByTestId('install-dropdown')).toBeInTheDocument() + }) + + it('should render DebugInfo when permission is admins and user is manager', () => { + render() + expect(screen.getByTestId('debug-info')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// Marketplace Installation Permission Tests +// ============================================================================ +describe('Marketplace Installation Permission', () => { + it('should show InstallPluginDropdown when marketplace is enabled and has permission', () => { + render() + expect(screen.getByTestId('install-dropdown')).toBeInTheDocument() + }) +}) + +// ============================================================================ +// Integration Tests +// ============================================================================ +describe('PluginPage Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(usePluginInstallation).mockReturnValue([ + { packageId: null, bundleInfo: null }, + vi.fn(), + ]) + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + }) + + it('should render complete plugin page with all features', () => { + render() + + // Check all major elements are present + expect(document.getElementById('marketplace-container')).toBeInTheDocument() + expect(screen.getByTestId('plugin-tasks')).toBeInTheDocument() + expect(screen.getByTestId('install-dropdown')).toBeInTheDocument() + expect(screen.getByTestId('debug-info')).toBeInTheDocument() + expect(screen.getByTestId('plugins-content')).toBeInTheDocument() + }) + + it('should handle full install from marketplace flow', async () => { + const mockSetInstallState = vi.fn() + vi.mocked(usePluginInstallation).mockReturnValue([ + { packageId: 'test-package', bundleInfo: null }, + mockSetInstallState, + ]) + + vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({ + data: { + plugin: { org: 'langgenius', name: 'test-plugin', category: 'tool' }, + version: { version: '1.0.0' }, + }, + } as Awaited>) + + render() + + // Wait for API call + await waitFor(() => { + expect(fetchManifestFromMarketPlace).toHaveBeenCalled() + }) + + // Wait for modal + await waitFor(() => { + expect(screen.getByTestId('install-marketplace-modal')).toBeInTheDocument() + }, { timeout: 3000 }) + + // Close modal + fireEvent.click(screen.getByText('Close')) + + // Verify state reset + await waitFor(() => { + expect(mockSetInstallState).toHaveBeenCalledWith(null) + }) + }) + + it('should handle full local plugin install flow', async () => { + vi.mocked(useQueryState).mockReturnValue(['plugins', vi.fn()]) + + render() + + const fileInput = document.getElementById('fileUploader') as HTMLInputElement + const file = new File(['plugin content'], 'my-plugin.difypkg', { + type: 'application/octet-stream', + }) + + Object.defineProperty(fileInput, 'files', { value: [file] }) + fireEvent.change(fileInput) + + await waitFor(() => { + expect(screen.getByTestId('install-local-modal')).toBeInTheDocument() + }) + + // Close modal (triggers removeFile via onClose) + fireEvent.click(screen.getByText('Close')) + + await waitFor(() => { + expect(screen.queryByTestId('install-local-modal')).not.toBeInTheDocument() + }) + }) + + it('should render marketplace content only when enable_marketplace is true', () => { + vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) + + const { rerender } = render() + + // With enable_marketplace: true (default mock), marketplace links should show + expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument() + + // Rerender to verify consistent behavior + rerender() + expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index 1f88f691ef..d852e4d0b8 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -207,6 +207,7 @@ const PluginPage = ({ popupContent={t('privilege.title', { ns: 'plugin' })} > + )} + /> + )} + + {/* Error Plugins Section */} + {errorPlugins.length > 0 && ( + + } + defaultStatusText={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })} + statusClassName="text-text-destructive break-all" + headerAction={( + + )} + renderItemAction={plugin => ( + + )} + /> + )} + + ) +} + +export default PluginTaskList diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx new file mode 100644 index 0000000000..084c8f90f9 --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx @@ -0,0 +1,96 @@ +import type { FC } from 'react' +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' + +export type TaskStatusIndicatorProps = { + tip: string + isInstalling: boolean + isInstallingWithSuccess: boolean + isInstallingWithError: boolean + isSuccess: boolean + isFailed: boolean + successPluginsLength: number + runningPluginsLength: number + totalPluginsLength: number + onClick: () => void +} + +const TaskStatusIndicator: FC = ({ + tip, + isInstalling, + isInstallingWithSuccess, + isInstallingWithError, + isSuccess, + isFailed, + successPluginsLength, + runningPluginsLength, + totalPluginsLength, + onClick, +}) => { + const showDownloadingIcon = isInstalling || isInstallingWithError + const showErrorStyle = isInstallingWithError || isFailed + const showSuccessIcon = isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0) + + return ( + +
+ {/* Main Icon */} + {showDownloadingIcon + ? + : ( + + )} + + {/* Status Indicator Badge */} +
+ {(isInstalling || isInstallingWithSuccess) && ( + 0 ? successPluginsLength / totalPluginsLength : 0) * 100} + circleFillColor="fill-components-progress-brand-bg" + /> + )} + {isInstallingWithError && ( + 0 ? runningPluginsLength / totalPluginsLength : 0) * 100} + circleFillColor="fill-components-progress-brand-bg" + sectorFillColor="fill-components-progress-error-border" + circleStrokeColor="stroke-components-progress-error-border" + /> + )} + {showSuccessIcon && !isInstalling && !isInstallingWithSuccess && !isInstallingWithError && ( + + )} + {isFailed && ( + + )} +
+
+
+ ) +} + +export default TaskStatusIndicator 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 new file mode 100644 index 0000000000..32892cbe28 --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx @@ -0,0 +1,856 @@ +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) + }) +}) 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 40dd4fedb1..45f1dce86b 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx @@ -1,33 +1,21 @@ -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 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 PluginTaskList from './components/plugin-task-list' +import TaskStatusIndicator from './components/task-status-indicator' import { usePluginTaskStatus } from './hooks' const PluginTasks = () => { const { t } = useTranslation() - const language = useGetLanguage() const [open, setOpen] = useState(false) const { errorPlugins, @@ -46,35 +34,7 @@ const PluginTasks = () => { } = 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]) - + // Generate tooltip text based on status const tip = useMemo(() => { if (isInstallingWithError) return t('task.installingWithError', { ns: 'plugin', installingLength: runningPluginsLength, successLength: successPluginsLength, errorLength: errorPluginsLength }) @@ -99,8 +59,38 @@ const PluginTasks = () => { t, ]) - // Show icon if there are any plugin tasks (completed, running, or failed) - // Only hide when there are absolutely no plugin tasks + // Generic clear function that handles clearing and modal closing + const clearPluginsAndClose = useCallback(async ( + plugins: Array<{ taskId: string, plugin_unique_identifier: string }>, + ) => { + for (const plugin of plugins) + await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier) + if (runningPluginsLength === 0) + setOpen(false) + }, [handleClearErrorPlugin, runningPluginsLength]) + + // Clear handlers using the generic function + const handleClearAll = useCallback( + () => clearPluginsAndClose([...successPlugins, ...errorPlugins]), + [clearPluginsAndClose, successPlugins, errorPlugins], + ) + + const handleClearErrors = useCallback( + () => clearPluginsAndClose(errorPlugins), + [clearPluginsAndClose, errorPlugins], + ) + + const handleClearSingle = useCallback( + (taskId: string, pluginId: string) => clearPluginsAndClose([{ taskId, plugin_unique_identifier: pluginId }]), + [clearPluginsAndClose], + ) + + const handleTriggerClick = useCallback(() => { + if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess) + setOpen(v => !v) + }, [isFailed, isInstalling, isInstallingWithSuccess, isInstallingWithError, isSuccess]) + + // Hide when no plugin tasks if (totalPluginsLength === 0) return null @@ -115,206 +105,30 @@ const PluginTasks = () => { crossAxis: 79, }} > - { - 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('task.installing', { ns: 'plugin' })} - {' '} - ( - {runningPlugins.length} - ) -
-
- {runningPlugins.map(runningPlugin => ( -
-
- - -
-
-
- {runningPlugin.labels[language]} -
-
- {t('task.installing', { ns: 'plugin' })} -
-
-
- ))} -
- - )} - - {/* Success Plugins */} - {successPlugins.length > 0 && ( - <> -
- {t('task.installed', { ns: 'plugin' })} - {' '} - ( - {successPlugins.length} - ) - -
-
- {successPlugins.map(successPlugin => ( -
-
- - -
-
-
- {successPlugin.labels[language]} -
-
- {successPlugin.message || t('task.installed', { ns: 'plugin' })} -
-
-
- ))} -
- - )} - - {/* Error Plugins */} - {errorPlugins.length > 0 && ( - <> -
- {t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })} - -
-
- {errorPlugins.map(errorPlugin => ( -
-
- - -
-
-
- {errorPlugin.labels[language]} -
-
- {errorPlugin.message} -
-
- -
- ))} -
- - )} -
+
diff --git a/web/app/components/plugins/plugin-page/use-reference-setting.spec.ts b/web/app/components/plugins/plugin-page/use-reference-setting.spec.ts new file mode 100644 index 0000000000..9f64d3fac5 --- /dev/null +++ b/web/app/components/plugins/plugin-page/use-reference-setting.spec.ts @@ -0,0 +1,388 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +// Import mocks for assertions +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' + +import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins' +import Toast from '../../base/toast' +import { PermissionType } from '../types' +import useReferenceSetting, { useCanInstallPluginFromMarketplace } from './use-reference-setting' + +// Mock dependencies +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: vi.fn(), +})) + +vi.mock('@/service/use-plugins', () => ({ + useReferenceSettings: vi.fn(), + useMutationReferenceSettings: vi.fn(), + useInvalidateReferenceSettings: vi.fn(), +})) + +vi.mock('../../base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +describe('useReferenceSetting Hook', () => { + beforeEach(() => { + vi.clearAllMocks() + + // Default mocks + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + } as ReturnType) + + vi.mocked(useReferenceSettings).mockReturnValue({ + data: { + permission: { + install_permission: PermissionType.everyone, + debug_permission: PermissionType.everyone, + }, + }, + } as ReturnType) + + vi.mocked(useMutationReferenceSettings).mockReturnValue({ + mutate: vi.fn(), + isPending: false, + } as unknown as ReturnType) + + vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn()) + }) + + describe('hasPermission logic', () => { + it('should return false when permission is undefined', () => { + vi.mocked(useReferenceSettings).mockReturnValue({ + data: { + permission: { + install_permission: undefined, + debug_permission: undefined, + }, + }, + } as unknown as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.canManagement).toBe(false) + expect(result.current.canDebugger).toBe(false) + }) + + it('should return false when permission is noOne', () => { + vi.mocked(useReferenceSettings).mockReturnValue({ + data: { + permission: { + install_permission: PermissionType.noOne, + debug_permission: PermissionType.noOne, + }, + }, + } as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.canManagement).toBe(false) + expect(result.current.canDebugger).toBe(false) + }) + + it('should return true when permission is everyone', () => { + vi.mocked(useReferenceSettings).mockReturnValue({ + data: { + permission: { + install_permission: PermissionType.everyone, + debug_permission: PermissionType.everyone, + }, + }, + } as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.canManagement).toBe(true) + expect(result.current.canDebugger).toBe(true) + }) + + it('should return isAdmin when permission is admin and user is manager', () => { + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: false, + } as ReturnType) + + vi.mocked(useReferenceSettings).mockReturnValue({ + data: { + permission: { + install_permission: PermissionType.admin, + debug_permission: PermissionType.admin, + }, + }, + } as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.canManagement).toBe(true) + expect(result.current.canDebugger).toBe(true) + }) + + it('should return isAdmin when permission is admin and user is owner', () => { + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: true, + } as ReturnType) + + vi.mocked(useReferenceSettings).mockReturnValue({ + data: { + permission: { + install_permission: PermissionType.admin, + debug_permission: PermissionType.admin, + }, + }, + } as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.canManagement).toBe(true) + expect(result.current.canDebugger).toBe(true) + }) + + it('should return false when permission is admin and user is not admin', () => { + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + } as ReturnType) + + vi.mocked(useReferenceSettings).mockReturnValue({ + data: { + permission: { + install_permission: PermissionType.admin, + debug_permission: PermissionType.admin, + }, + }, + } as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.canManagement).toBe(false) + expect(result.current.canDebugger).toBe(false) + }) + }) + + describe('canSetPermissions', () => { + it('should be true when user is workspace manager', () => { + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: false, + } as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.canSetPermissions).toBe(true) + }) + + it('should be true when user is workspace owner', () => { + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: true, + } as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.canSetPermissions).toBe(true) + }) + + it('should be false when user is neither manager nor owner', () => { + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + } as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.canSetPermissions).toBe(false) + }) + }) + + describe('setReferenceSettings callback', () => { + it('should call invalidateReferenceSettings and show toast on success', async () => { + const mockInvalidate = vi.fn() + vi.mocked(useInvalidateReferenceSettings).mockReturnValue(mockInvalidate) + + let onSuccessCallback: (() => void) | undefined + vi.mocked(useMutationReferenceSettings).mockImplementation((options) => { + onSuccessCallback = options?.onSuccess as () => void + return { + mutate: vi.fn(), + isPending: false, + } as unknown as ReturnType + }) + + renderHook(() => useReferenceSetting()) + + // Trigger the onSuccess callback + if (onSuccessCallback) + onSuccessCallback() + + await waitFor(() => { + expect(mockInvalidate).toHaveBeenCalled() + expect(Toast.notify).toHaveBeenCalledWith({ + type: 'success', + message: 'api.actionSuccess', + }) + }) + }) + }) + + describe('returned values', () => { + it('should return referenceSetting data', () => { + const mockData = { + permission: { + install_permission: PermissionType.everyone, + debug_permission: PermissionType.everyone, + }, + } + vi.mocked(useReferenceSettings).mockReturnValue({ + data: mockData, + } as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.referenceSetting).toEqual(mockData) + }) + + it('should return isUpdatePending from mutation', () => { + vi.mocked(useMutationReferenceSettings).mockReturnValue({ + mutate: vi.fn(), + isPending: true, + } as unknown as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.isUpdatePending).toBe(true) + }) + + it('should handle null data', () => { + vi.mocked(useReferenceSettings).mockReturnValue({ + data: null, + } as unknown as ReturnType) + + const { result } = renderHook(() => useReferenceSetting()) + + expect(result.current.canManagement).toBe(false) + expect(result.current.canDebugger).toBe(false) + }) + }) +}) + +describe('useCanInstallPluginFromMarketplace Hook', () => { + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: false, + } as ReturnType) + + vi.mocked(useReferenceSettings).mockReturnValue({ + data: { + permission: { + install_permission: PermissionType.everyone, + debug_permission: PermissionType.everyone, + }, + }, + } as ReturnType) + + vi.mocked(useMutationReferenceSettings).mockReturnValue({ + mutate: vi.fn(), + isPending: false, + } as unknown as ReturnType) + + vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn()) + }) + + it('should return true when marketplace is enabled and canManagement is true', () => { + vi.mocked(useGlobalPublicStore).mockImplementation((selector) => { + const state = { + systemFeatures: { + enable_marketplace: true, + }, + } + return selector(state as Parameters[0]) + }) + + const { result } = renderHook(() => useCanInstallPluginFromMarketplace()) + + expect(result.current.canInstallPluginFromMarketplace).toBe(true) + }) + + it('should return false when marketplace is disabled', () => { + vi.mocked(useGlobalPublicStore).mockImplementation((selector) => { + const state = { + systemFeatures: { + enable_marketplace: false, + }, + } + return selector(state as Parameters[0]) + }) + + const { result } = renderHook(() => useCanInstallPluginFromMarketplace()) + + expect(result.current.canInstallPluginFromMarketplace).toBe(false) + }) + + it('should return false when canManagement is false', () => { + vi.mocked(useGlobalPublicStore).mockImplementation((selector) => { + const state = { + systemFeatures: { + enable_marketplace: true, + }, + } + return selector(state as Parameters[0]) + }) + + vi.mocked(useReferenceSettings).mockReturnValue({ + data: { + permission: { + install_permission: PermissionType.noOne, + debug_permission: PermissionType.noOne, + }, + }, + } as ReturnType) + + const { result } = renderHook(() => useCanInstallPluginFromMarketplace()) + + expect(result.current.canInstallPluginFromMarketplace).toBe(false) + }) + + it('should return false when both marketplace is disabled and canManagement is false', () => { + vi.mocked(useGlobalPublicStore).mockImplementation((selector) => { + const state = { + systemFeatures: { + enable_marketplace: false, + }, + } + return selector(state as Parameters[0]) + }) + + vi.mocked(useReferenceSettings).mockReturnValue({ + data: { + permission: { + install_permission: PermissionType.noOne, + debug_permission: PermissionType.noOne, + }, + }, + } as ReturnType) + + const { result } = renderHook(() => useCanInstallPluginFromMarketplace()) + + expect(result.current.canInstallPluginFromMarketplace).toBe(false) + }) +}) diff --git a/web/app/components/plugins/plugin-page/use-uploader.spec.ts b/web/app/components/plugins/plugin-page/use-uploader.spec.ts new file mode 100644 index 0000000000..fa9463b7c0 --- /dev/null +++ b/web/app/components/plugins/plugin-page/use-uploader.spec.ts @@ -0,0 +1,487 @@ +import type { RefObject } from 'react' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useUploader } from './use-uploader' + +describe('useUploader Hook', () => { + let mockContainerRef: RefObject + let mockOnFileChange: (file: File | null) => void + let mockContainer: HTMLDivElement + + beforeEach(() => { + vi.clearAllMocks() + + mockContainer = document.createElement('div') + document.body.appendChild(mockContainer) + + mockContainerRef = { current: mockContainer } + mockOnFileChange = vi.fn() + }) + + afterEach(() => { + if (mockContainer.parentNode) + document.body.removeChild(mockContainer) + }) + + describe('Initial State', () => { + it('should return initial state with dragging false', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + expect(result.current.dragging).toBe(false) + expect(result.current.fileUploader.current).toBeNull() + expect(result.current.fileChangeHandle).not.toBeNull() + expect(result.current.removeFile).not.toBeNull() + }) + + it('should return null handlers when disabled', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + enabled: false, + }), + ) + + expect(result.current.dragging).toBe(false) + expect(result.current.fileChangeHandle).toBeNull() + expect(result.current.removeFile).toBeNull() + }) + }) + + describe('Drag Events', () => { + it('should handle dragenter and set dragging to true', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'] }, + }) + + act(() => { + mockContainer.dispatchEvent(dragEnterEvent) + }) + + expect(result.current.dragging).toBe(true) + }) + + it('should not set dragging when dragenter without Files type', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['text/plain'] }, + }) + + act(() => { + mockContainer.dispatchEvent(dragEnterEvent) + }) + + expect(result.current.dragging).toBe(false) + }) + + it('should handle dragover event', () => { + renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true }) + + act(() => { + mockContainer.dispatchEvent(dragOverEvent) + }) + + // dragover should prevent default and stop propagation + expect(mockContainer).toBeInTheDocument() + }) + + it('should handle dragleave when relatedTarget is null', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + // First set dragging to true + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'] }, + }) + act(() => { + mockContainer.dispatchEvent(dragEnterEvent) + }) + expect(result.current.dragging).toBe(true) + + // Then trigger dragleave with null relatedTarget + const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true }) + Object.defineProperty(dragLeaveEvent, 'relatedTarget', { + value: null, + }) + + act(() => { + mockContainer.dispatchEvent(dragLeaveEvent) + }) + + expect(result.current.dragging).toBe(false) + }) + + it('should handle dragleave when relatedTarget is outside container', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + // First set dragging to true + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'] }, + }) + act(() => { + mockContainer.dispatchEvent(dragEnterEvent) + }) + expect(result.current.dragging).toBe(true) + + // Create element outside container + const outsideElement = document.createElement('div') + document.body.appendChild(outsideElement) + + // Trigger dragleave with relatedTarget outside container + const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true }) + Object.defineProperty(dragLeaveEvent, 'relatedTarget', { + value: outsideElement, + }) + + act(() => { + mockContainer.dispatchEvent(dragLeaveEvent) + }) + + expect(result.current.dragging).toBe(false) + document.body.removeChild(outsideElement) + }) + + it('should not set dragging to false when relatedTarget is inside container', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + // First set dragging to true + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'] }, + }) + act(() => { + mockContainer.dispatchEvent(dragEnterEvent) + }) + expect(result.current.dragging).toBe(true) + + // Create element inside container + const insideElement = document.createElement('div') + mockContainer.appendChild(insideElement) + + // Trigger dragleave with relatedTarget inside container + const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true }) + Object.defineProperty(dragLeaveEvent, 'relatedTarget', { + value: insideElement, + }) + + act(() => { + mockContainer.dispatchEvent(dragLeaveEvent) + }) + + // Should still be dragging since relatedTarget is inside container + expect(result.current.dragging).toBe(true) + }) + }) + + describe('Drop Events', () => { + it('should handle drop event with files', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + // First set dragging to true + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'] }, + }) + act(() => { + mockContainer.dispatchEvent(dragEnterEvent) + }) + + // Create mock file + const file = new File(['content'], 'test.difypkg', { type: 'application/octet-stream' }) + + // Trigger drop event + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [file] }, + }) + + act(() => { + mockContainer.dispatchEvent(dropEvent) + }) + + expect(result.current.dragging).toBe(false) + expect(mockOnFileChange).toHaveBeenCalledWith(file) + }) + + it('should not call onFileChange when drop has no dataTransfer', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + // Set dragging first + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + Object.defineProperty(dragEnterEvent, 'dataTransfer', { + value: { types: ['Files'] }, + }) + act(() => { + mockContainer.dispatchEvent(dragEnterEvent) + }) + + // Drop without dataTransfer + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + // No dataTransfer property + + act(() => { + mockContainer.dispatchEvent(dropEvent) + }) + + expect(result.current.dragging).toBe(false) + expect(mockOnFileChange).not.toHaveBeenCalled() + }) + + it('should not call onFileChange when drop has empty files array', () => { + renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [] }, + }) + + act(() => { + mockContainer.dispatchEvent(dropEvent) + }) + + expect(mockOnFileChange).not.toHaveBeenCalled() + }) + }) + + describe('File Change Handler', () => { + it('should call onFileChange with file from input', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + const file = new File(['content'], 'test.difypkg', { type: 'application/octet-stream' }) + const mockEvent = { + target: { + files: [file], + }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle?.(mockEvent) + }) + + expect(mockOnFileChange).toHaveBeenCalledWith(file) + }) + + it('should call onFileChange with null when no files', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + const mockEvent = { + target: { + files: null, + }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle?.(mockEvent) + }) + + expect(mockOnFileChange).toHaveBeenCalledWith(null) + }) + }) + + describe('Remove File', () => { + it('should call onFileChange with null', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + act(() => { + result.current.removeFile?.() + }) + + expect(mockOnFileChange).toHaveBeenCalledWith(null) + }) + + it('should handle removeFile when fileUploader has a value', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + // Create a mock input element with value property + const mockInput = { + value: 'test.difypkg', + } + + // Override the fileUploader ref + Object.defineProperty(result.current.fileUploader, 'current', { + value: mockInput, + writable: true, + }) + + act(() => { + result.current.removeFile?.() + }) + + expect(mockOnFileChange).toHaveBeenCalledWith(null) + expect(mockInput.value).toBe('') + }) + + it('should handle removeFile when fileUploader is null', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + }), + ) + + // fileUploader.current is null by default + act(() => { + result.current.removeFile?.() + }) + + expect(mockOnFileChange).toHaveBeenCalledWith(null) + }) + }) + + describe('Enabled/Disabled State', () => { + it('should not add event listeners when disabled', () => { + const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener') + + renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + enabled: false, + }), + ) + + expect(addEventListenerSpy).not.toHaveBeenCalled() + }) + + it('should add event listeners when enabled', () => { + const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener') + + renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + enabled: true, + }), + ) + + expect(addEventListenerSpy).toHaveBeenCalledWith('dragenter', expect.any(Function)) + expect(addEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function)) + expect(addEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function)) + expect(addEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function)) + }) + + it('should remove event listeners on cleanup', () => { + const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener') + + const { unmount } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + enabled: true, + }), + ) + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('dragenter', expect.any(Function)) + expect(removeEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function)) + expect(removeEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function)) + expect(removeEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function)) + }) + + it('should return false for dragging when disabled', () => { + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: mockContainerRef, + enabled: false, + }), + ) + + expect(result.current.dragging).toBe(false) + }) + }) + + describe('Container Ref Edge Cases', () => { + it('should handle null containerRef.current', () => { + const nullRef: RefObject = { current: null } + + const { result } = renderHook(() => + useUploader({ + onFileChange: mockOnFileChange, + containerRef: nullRef, + }), + ) + + expect(result.current.dragging).toBe(false) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/index.spec.tsx b/web/app/components/rag-pipeline/index.spec.tsx new file mode 100644 index 0000000000..5adfc828cf --- /dev/null +++ b/web/app/components/rag-pipeline/index.spec.tsx @@ -0,0 +1,550 @@ +import type { FetchWorkflowDraftResponse } from '@/types/workflow' +import { cleanup, render, screen } from '@testing-library/react' +import * as React from 'react' +import { BlockEnum } from '@/app/components/workflow/types' + +// Import real utility functions (pure functions, no side effects) + +// Import mocked modules for manipulation +import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' +import { usePipelineInit } from './hooks' +import RagPipelineWrapper from './index' +import { processNodesWithoutDataSource } from './utils' + +// Mock: Context - need to control return values +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: vi.fn(), +})) + +// Mock: Hook with API calls +vi.mock('./hooks', () => ({ + usePipelineInit: vi.fn(), +})) + +// Mock: Store creator +vi.mock('./store', () => ({ + createRagPipelineSliceSlice: vi.fn(() => ({})), +})) + +// Mock: Utility with complex workflow dependencies (generateNewNode, etc.) +vi.mock('./utils', () => ({ + processNodesWithoutDataSource: vi.fn((nodes, viewport) => ({ + nodes, + viewport, + })), +})) + +// Mock: Complex component with useParams, Toast, API calls +vi.mock('./components/conversion', () => ({ + default: () =>
Conversion Component
, +})) + +// Mock: Complex component with many hooks and workflow dependencies +vi.mock('./components/rag-pipeline-main', () => ({ + default: ({ nodes, edges, viewport }: any) => ( +
+ {nodes?.length ?? 0} + {edges?.length ?? 0} + {viewport?.zoom ?? 'none'} +
+ ), +})) + +// Mock: Complex component with ReactFlow and many providers +vi.mock('@/app/components/workflow', () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +// Mock: Context provider +vi.mock('@/app/components/workflow/context', () => ({ + WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +// Type assertions for mocked functions +const mockUseDatasetDetailContextWithSelector = vi.mocked(useDatasetDetailContextWithSelector) +const mockUsePipelineInit = vi.mocked(usePipelineInit) +const mockProcessNodesWithoutDataSource = vi.mocked(processNodesWithoutDataSource) + +// Helper to mock selector with actual execution (increases function coverage) +// This executes the real selector function: s => s.dataset?.pipeline_id +const mockSelectorWithDataset = (pipelineId: string | null | undefined) => { + mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: any) => any) => { + const mockState = { dataset: pipelineId ? { pipeline_id: pipelineId } : null } + return selector(mockState) + }) +} + +// Test data factory +const createMockWorkflowData = (overrides?: Partial): FetchWorkflowDraftResponse => ({ + graph: { + nodes: [ + { id: 'node-1', type: 'custom', data: { type: BlockEnum.Start, title: 'Start' }, position: { x: 100, y: 100 } }, + { id: 'node-2', type: 'custom', data: { type: BlockEnum.End, title: 'End' }, position: { x: 300, y: 100 } }, + ], + edges: [ + { id: 'edge-1', source: 'node-1', target: 'node-2', type: 'custom' }, + ], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + hash: 'test-hash-123', + updated_at: 1234567890, + tool_published: false, + environment_variables: [], + ...overrides, +} as FetchWorkflowDraftResponse) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('RagPipelineWrapper', () => { + describe('Rendering', () => { + it('should render Conversion component when pipelineId is null', () => { + mockSelectorWithDataset(null) + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false }) + + render() + + expect(screen.getByTestId('conversion-component')).toBeInTheDocument() + expect(screen.queryByTestId('workflow-context-provider')).not.toBeInTheDocument() + }) + + it('should render Conversion component when pipelineId is undefined', () => { + mockSelectorWithDataset(undefined) + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false }) + + render() + + expect(screen.getByTestId('conversion-component')).toBeInTheDocument() + }) + + it('should render Conversion component when pipelineId is empty string', () => { + mockSelectorWithDataset('') + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false }) + + render() + + expect(screen.getByTestId('conversion-component')).toBeInTheDocument() + }) + + it('should render WorkflowContextProvider when pipelineId exists', () => { + mockSelectorWithDataset('pipeline-123') + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true }) + + render() + + expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument() + expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument() + }) + }) + + describe('Props Variations', () => { + it('should pass injectWorkflowStoreSliceFn to WorkflowContextProvider', () => { + mockSelectorWithDataset('pipeline-456') + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true }) + + render() + + expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument() + }) + }) +}) + +describe('RagPipeline', () => { + beforeEach(() => { + // Default setup for RagPipeline tests - execute real selector function + mockSelectorWithDataset('pipeline-123') + }) + + describe('Loading State', () => { + it('should render Loading component when isLoading is true', () => { + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true }) + + render() + + // Real Loading component has role="status" + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should render Loading component when data is undefined', () => { + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false }) + + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should render Loading component when both data is undefined and isLoading is true', () => { + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true }) + + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + }) + + describe('Data Loaded State', () => { + it('should render RagPipelineMain when data is loaded', () => { + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument() + expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() + }) + + it('should pass processed nodes to RagPipelineMain', () => { + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('nodes-count').textContent).toBe('2') + }) + + it('should pass edges to RagPipelineMain', () => { + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('edges-count').textContent).toBe('1') + }) + + it('should pass viewport to RagPipelineMain', () => { + const mockData = createMockWorkflowData({ + graph: { + nodes: [], + edges: [], + viewport: { x: 100, y: 200, zoom: 1.5 }, + }, + }) + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('viewport-zoom').textContent).toBe('1.5') + }) + }) + + describe('Memoization Logic', () => { + it('should process nodes through initialNodes when data is loaded', () => { + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + // initialNodes is a real function - verify nodes are rendered + // The real initialNodes processes nodes and adds position data + expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument() + }) + + it('should process edges through initialEdges when data is loaded', () => { + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + // initialEdges is a real function - verify component renders with edges + expect(screen.getByTestId('edges-count').textContent).toBe('1') + }) + + it('should call processNodesWithoutDataSource with nodesData and viewport', () => { + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(mockProcessNodesWithoutDataSource).toHaveBeenCalled() + }) + + it('should not process nodes when data is undefined', () => { + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false }) + + render() + + // When data is undefined, Loading is shown, processNodesWithoutDataSource is not called + expect(mockProcessNodesWithoutDataSource).not.toHaveBeenCalled() + }) + + it('should use memoized values when data reference is same', () => { + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + const { rerender } = render() + + // Clear mock call count after initial render + mockProcessNodesWithoutDataSource.mockClear() + + // Rerender with same data reference (no change to mockUsePipelineInit) + rerender() + + // processNodesWithoutDataSource should not be called again due to useMemo + // Note: React strict mode may cause double render, so we check it's not excessive + expect(mockProcessNodesWithoutDataSource.mock.calls.length).toBeLessThanOrEqual(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty nodes array', () => { + const mockData = createMockWorkflowData({ + graph: { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + }) + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('nodes-count').textContent).toBe('0') + }) + + it('should handle empty edges array', () => { + const mockData = createMockWorkflowData({ + graph: { + nodes: [{ id: 'node-1', type: 'custom', data: { type: BlockEnum.Start, title: 'Start', desc: '' }, position: { x: 0, y: 0 } }], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + }) + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('edges-count').textContent).toBe('0') + }) + + it('should handle undefined viewport', () => { + const mockData = createMockWorkflowData({ + graph: { + nodes: [], + edges: [], + viewport: undefined as any, + }, + }) + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument() + }) + + it('should handle null viewport', () => { + const mockData = createMockWorkflowData({ + graph: { + nodes: [], + edges: [], + viewport: null as any, + }, + }) + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument() + }) + + it('should handle large number of nodes', () => { + const largeNodesArray = Array.from({ length: 100 }, (_, i) => ({ + id: `node-${i}`, + type: 'custom', + data: { type: BlockEnum.Start, title: `Node ${i}`, desc: '' }, + position: { x: i * 100, y: 0 }, + })) + + const mockData = createMockWorkflowData({ + graph: { + nodes: largeNodesArray, + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + }) + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('nodes-count').textContent).toBe('100') + }) + + it('should handle viewport with edge case zoom values', () => { + const mockData = createMockWorkflowData({ + graph: { + nodes: [], + edges: [], + viewport: { x: -1000, y: -1000, zoom: 0.25 }, + }, + }) + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('viewport-zoom').textContent).toBe('0.25') + }) + + it('should handle viewport with maximum zoom', () => { + const mockData = createMockWorkflowData({ + graph: { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 4 }, + }, + }) + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('viewport-zoom').textContent).toBe('4') + }) + }) + + describe('Component Integration', () => { + it('should render WorkflowWithDefaultContext as wrapper', () => { + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument() + }) + + it('should nest RagPipelineMain inside WorkflowWithDefaultContext', () => { + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + render() + + const workflowContext = screen.getByTestId('workflow-default-context') + const ragPipelineMain = screen.getByTestId('rag-pipeline-main') + + expect(workflowContext).toContainElement(ragPipelineMain) + }) + }) +}) + +describe('processNodesWithoutDataSource utility integration', () => { + beforeEach(() => { + mockSelectorWithDataset('pipeline-123') + }) + + it('should process nodes through processNodesWithoutDataSource', () => { + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + mockProcessNodesWithoutDataSource.mockReturnValue({ + nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as any, + viewport: { x: 0, y: 0, zoom: 2 }, + }) + + render() + + expect(mockProcessNodesWithoutDataSource).toHaveBeenCalled() + expect(screen.getByTestId('nodes-count').textContent).toBe('1') + expect(screen.getByTestId('viewport-zoom').textContent).toBe('2') + }) + + it('should handle processNodesWithoutDataSource returning modified viewport', () => { + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + mockProcessNodesWithoutDataSource.mockReturnValue({ + nodes: [], + viewport: { x: 500, y: 500, zoom: 0.5 }, + }) + + render() + + expect(screen.getByTestId('viewport-zoom').textContent).toBe('0.5') + }) +}) + +describe('Conditional Rendering Flow', () => { + it('should transition from loading to loaded state', () => { + mockSelectorWithDataset('pipeline-123') + + // Start with loading state + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true }) + const { rerender } = render() + + // Real Loading component has role="status" + expect(screen.getByRole('status')).toBeInTheDocument() + + // Transition to loaded state + const mockData = createMockWorkflowData() + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + rerender() + + expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument() + }) + + it('should switch from Conversion to Pipeline when pipelineId becomes available', () => { + // Start without pipelineId + mockSelectorWithDataset(null) + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false }) + + const { rerender } = render() + + expect(screen.getByTestId('conversion-component')).toBeInTheDocument() + + // PipelineId becomes available + mockSelectorWithDataset('new-pipeline-id') + mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true }) + rerender() + + expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument() + // Real Loading component has role="status" + expect(screen.getByRole('status')).toBeInTheDocument() + }) +}) + +describe('Error Handling', () => { + beforeEach(() => { + mockSelectorWithDataset('pipeline-123') + }) + + it('should throw when graph nodes is null', () => { + const mockData = { + graph: { + nodes: null as any, + edges: null as any, + viewport: { x: 0, y: 0, zoom: 1 }, + }, + hash: 'test', + updated_at: 123, + } as FetchWorkflowDraftResponse + + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + // Suppress console.error for expected error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // Real initialNodes will throw when nodes is null + // This documents the component's current behavior - it requires valid nodes array + expect(() => render()).toThrow() + + consoleSpy.mockRestore() + }) + + it('should throw when graph property is missing', () => { + const mockData = { + hash: 'test', + updated_at: 123, + } as unknown as FetchWorkflowDraftResponse + + mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false }) + + // Suppress console.error for expected error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // When graph is undefined, component throws because data.graph.nodes is accessed + // This documents the component's current behavior - it requires graph to be present + expect(() => render()).toThrow() + + consoleSpy.mockRestore() + }) +})