diff --git a/web/app/components/plugins/install-plugin/install-bundle/index.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/index.spec.tsx new file mode 100644 index 0000000000..1b70cfb5c7 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/index.spec.tsx @@ -0,0 +1,1431 @@ +import type { Dependency, GitHubItemAndMarketPlaceDependency, InstallStatus, PackageDependency, Plugin, PluginDeclaration, VersionProps } from '../../types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { InstallStep, PluginCategoryEnum } from '../../types' +import InstallBundle, { InstallType } from './index' +import GithubItem from './item/github-item' +import LoadedItem from './item/loaded-item' +import MarketplaceItem from './item/marketplace-item' +import PackageItem from './item/package-item' +import ReadyToInstall from './ready-to-install' +import Installed from './steps/installed' + +// Factory functions for test data +const createMockPlugin = (overrides: Partial = {}): Plugin => ({ + type: 'plugin', + org: 'test-org', + name: 'Test Plugin', + plugin_id: 'test-plugin-id', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'test-package-id', + icon: 'test-icon.png', + verified: true, + label: { 'en-US': 'Test Plugin' }, + brief: { 'en-US': 'A test plugin' }, + description: { 'en-US': 'A test plugin description' }, + introduction: 'Introduction text', + repository: 'https://github.com/test/plugin', + category: PluginCategoryEnum.tool, + install_count: 100, + endpoint: { settings: [] }, + tags: [], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +const createMockVersionProps = (overrides: Partial = {}): VersionProps => ({ + hasInstalled: false, + installedVersion: undefined, + toInstallVersion: '1.0.0', + ...overrides, +}) + +const createMockInstallStatus = (overrides: Partial = {}): InstallStatus => ({ + success: true, + isFromMarketPlace: true, + ...overrides, +}) + +const createMockGitHubDependency = (): GitHubItemAndMarketPlaceDependency => ({ + type: 'github', + value: { + repo: 'test-org/test-repo', + version: 'v1.0.0', + package: 'plugin.zip', + }, +}) + +const createMockPackageDependency = (): PackageDependency => ({ + type: 'package', + value: { + unique_identifier: 'package-plugin-uid', + manifest: { + plugin_unique_identifier: 'package-plugin-uid', + version: '1.0.0', + author: 'test-author', + icon: 'icon.png', + name: 'Package Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Package Plugin' } as Record, + description: { 'en-US': 'Test package plugin' } as Record, + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], + }, + }, +}) + +const createMockDependency = (overrides: Partial = {}): Dependency => ({ + type: 'marketplace', + value: { + plugin_unique_identifier: 'test-plugin-uid', + }, + ...overrides, +} as Dependency) + +const createMockDependencies = (): Dependency[] => [ + { + type: 'marketplace', + value: { + marketplace_plugin_unique_identifier: 'plugin-1-uid', + }, + }, + { + type: 'github', + value: { + repo: 'test/plugin2', + version: 'v1.0.0', + package: 'plugin2.zip', + }, + }, + { + type: 'package', + value: { + unique_identifier: 'package-plugin-uid', + manifest: { + plugin_unique_identifier: 'package-plugin-uid', + version: '1.0.0', + author: 'test-author', + icon: 'icon.png', + name: 'Package Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Package Plugin' } as Record, + description: { 'en-US': 'Test package plugin' } as Record, + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], + }, + }, + }, +] + +// Mock useHideLogic hook +let mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), +} +vi.mock('../hooks/use-hide-logic', () => ({ + default: () => mockHideLogicState, +})) + +// Mock useGetIcon hook +vi.mock('../base/use-get-icon', () => ({ + default: () => ({ + getIconUrl: (icon: string) => icon || 'default-icon.png', + }), +})) + +// Mock usePluginInstallLimit hook +vi.mock('../hooks/use-install-plugin-limit', () => ({ + default: () => ({ canInstall: true }), + pluginInstallLimit: () => ({ canInstall: true }), +})) + +// Mock useUploadGitHub hook +const mockUseUploadGitHub = vi.fn() +vi.mock('@/service/use-plugins', () => ({ + useUploadGitHub: (params: { repo: string, version: string, package: string }) => mockUseUploadGitHub(params), + useInstallOrUpdate: () => ({ mutate: vi.fn(), isPending: false }), + usePluginTaskList: () => ({ handleRefetch: vi.fn() }), + useFetchPluginsInMarketPlaceByInfo: () => ({ isLoading: false, data: null, error: null }), +})) + +// Mock config +vi.mock('@/config', () => ({ + MARKETPLACE_API_PREFIX: 'https://marketplace.example.com', +})) + +// Mock mitt context +vi.mock('@/context/mitt-context', () => ({ + useMittContextSelector: () => vi.fn(), +})) + +// Mock global public context +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: () => ({}), +})) + +// Mock useCanInstallPluginFromMarketplace +vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ + useCanInstallPluginFromMarketplace: () => ({ canInstallPluginFromMarketplace: true }), +})) + +// Mock checkTaskStatus +vi.mock('../base/check-task-status', () => ({ + default: () => ({ check: vi.fn(), stop: vi.fn() }), +})) + +// Mock useRefreshPluginList +vi.mock('../hooks/use-refresh-plugin-list', () => ({ + default: () => ({ refreshPluginList: vi.fn() }), +})) + +// Mock useCheckInstalled +vi.mock('../hooks/use-check-installed', () => ({ + default: () => ({ installedInfo: {} }), +})) + +// Mock ReadyToInstall child component to test InstallBundle in isolation +vi.mock('./ready-to-install', () => ({ + default: ({ + step, + onStepChange, + onStartToInstall, + setIsInstalling, + allPlugins, + onClose, + }: { + step: InstallStep + onStepChange: (step: InstallStep) => void + onStartToInstall: () => void + setIsInstalling: (isInstalling: boolean) => void + allPlugins: Dependency[] + onClose: () => void + }) => ( +
+ {step} + {allPlugins?.length || 0} + + + + + + + +
+ ), +})) + +describe('InstallBundle', () => { + const defaultProps = { + fromDSLPayload: createMockDependencies(), + onClose: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), + } + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render modal with correct title for install plugin', () => { + render() + + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should render ReadyToInstall component', () => { + render() + + expect(screen.getByTestId('ready-to-install')).toBeInTheDocument() + }) + + it('should integrate with useHideLogic hook', () => { + render() + + // Verify that the component integrates with useHideLogic + // The hook provides modalClassName, foldAnimInto, setIsInstalling, handleStartToInstall + expect(mockHideLogicState.modalClassName).toBeDefined() + expect(mockHideLogicState.foldAnimInto).toBeDefined() + }) + + it('should render modal as visible', () => { + render() + + // Modal is always shown (isShow={true}) + expect(screen.getByText('plugin.installModal.installPlugin')).toBeVisible() + }) + }) + + // ================================ + // Props Tests + // ================================ + describe('Props', () => { + describe('installType', () => { + it('should default to InstallType.fromMarketplace when not provided', () => { + render() + + // When installType is fromMarketplace (default), initial step should be readyToInstall + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall) + }) + + it('should set initial step to readyToInstall when installType is fromMarketplace', () => { + render() + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall) + }) + + it('should set initial step to uploading when installType is fromLocal', () => { + render() + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.uploading) + }) + + it('should set initial step to uploading when installType is fromDSL', () => { + render() + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.uploading) + }) + }) + + describe('fromDSLPayload', () => { + it('should pass allPlugins to ReadyToInstall', () => { + const plugins = createMockDependencies() + render() + + expect(screen.getByTestId('plugins-count')).toHaveTextContent('3') + }) + + it('should handle empty fromDSLPayload array', () => { + render() + + expect(screen.getByTestId('plugins-count')).toHaveTextContent('0') + }) + + it('should handle single plugin in fromDSLPayload', () => { + render() + + expect(screen.getByTestId('plugins-count')).toHaveTextContent('1') + }) + }) + + describe('onClose', () => { + it('should pass onClose to ReadyToInstall', () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByTestId('close-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + }) + + // ================================ + // State Management Tests + // ================================ + describe('State Management', () => { + it('should update title when step changes to uploadFailed', () => { + render() + + // Initial title + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + + // Change step to uploadFailed + fireEvent.click(screen.getByTestId('change-to-upload-failed')) + + expect(screen.getByText('plugin.installModal.uploadFailed')).toBeInTheDocument() + }) + + it('should update title when step changes to installed', () => { + render() + + // Change step to installed + fireEvent.click(screen.getByTestId('change-to-installed')) + + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + }) + + it('should maintain installPlugin title for readyToInstall step', () => { + render() + + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + + // Explicitly change to readyToInstall + fireEvent.click(screen.getByTestId('change-to-ready')) + + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should pass step state to ReadyToInstall component', () => { + render() + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall) + }) + + it('should update ReadyToInstall step when onStepChange is called', () => { + render() + + // Initially readyToInstall + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall) + + // Change to installed + fireEvent.click(screen.getByTestId('change-to-installed')) + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.installed) + }) + }) + + // ================================ + // Callback Stability and useHideLogic Integration Tests + // ================================ + describe('Callback Stability and useHideLogic Integration', () => { + it('should provide foldAnimInto for modal onClose handler', () => { + render() + + // The modal's onClose is set to foldAnimInto from useHideLogic + // Verify the hook provides this function + expect(mockHideLogicState.foldAnimInto).toBeDefined() + expect(typeof mockHideLogicState.foldAnimInto).toBe('function') + }) + + it('should pass handleStartToInstall to ReadyToInstall', () => { + render() + + fireEvent.click(screen.getByTestId('start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1) + }) + + it('should pass setIsInstalling to ReadyToInstall', () => { + render() + + fireEvent.click(screen.getByTestId('set-installing-true')) + + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(true) + }) + + it('should pass setIsInstalling with false to ReadyToInstall', () => { + render() + + fireEvent.click(screen.getByTestId('set-installing-false')) + + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + + // ================================ + // Title Logic Tests (getTitle callback) + // ================================ + describe('Title Logic (getTitle callback)', () => { + it('should return uploadFailed title when step is uploadFailed', () => { + render() + + fireEvent.click(screen.getByTestId('change-to-upload-failed')) + + expect(screen.getByText('plugin.installModal.uploadFailed')).toBeInTheDocument() + }) + + it('should return installComplete title when step is installed', () => { + render() + + fireEvent.click(screen.getByTestId('change-to-installed')) + + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + }) + + it('should return installPlugin title for all other steps', () => { + render() + + // Default step - readyToInstall + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should return installPlugin title when step is uploading', () => { + render() + + // Step is uploading + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + }) + + // ================================ + // Component Memoization Tests + // ================================ + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + // Verify that InstallBundle is memoized by checking its displayName or structure + // Since the component is exported as React.memo(InstallBundle), we can check its type + expect(InstallBundle).toBeDefined() + expect(typeof InstallBundle).toBe('object') // memo returns an object + }) + + it('should not re-render when same props are passed', () => { + const onClose = vi.fn() + const payload = createMockDependencies() + + const { rerender } = render( + , + ) + + // Re-render with same props reference + rerender() + + // Component should still render correctly + expect(screen.getByTestId('ready-to-install')).toBeInTheDocument() + }) + }) + + // ================================ + // User Interactions Tests + // ================================ + describe('User Interactions', () => { + it('should handle start install button click', () => { + render() + + fireEvent.click(screen.getByTestId('start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1) + }) + + it('should handle close button click', () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByTestId('close-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should handle step change to installed', () => { + render() + + fireEvent.click(screen.getByTestId('change-to-installed')) + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.installed) + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + }) + + it('should handle step change to uploadFailed', () => { + render() + + fireEvent.click(screen.getByTestId('change-to-upload-failed')) + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.uploadFailed) + expect(screen.getByText('plugin.installModal.uploadFailed')).toBeInTheDocument() + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty dependencies array', () => { + render() + + expect(screen.getByTestId('plugins-count')).toHaveTextContent('0') + }) + + it('should handle large number of dependencies', () => { + const largeDependencies: Dependency[] = Array.from({ length: 100 }, (_, i) => ({ + type: 'marketplace', + value: { + marketplace_plugin_unique_identifier: `plugin-${i}-uid`, + }, + })) + + render() + + expect(screen.getByTestId('plugins-count')).toHaveTextContent('100') + }) + + it('should handle dependencies with different types', () => { + const mixedDependencies: Dependency[] = [ + { type: 'marketplace', value: { marketplace_plugin_unique_identifier: 'mp-uid' } }, + { type: 'github', value: { repo: 'org/repo', version: 'v1.0.0', package: 'pkg.zip' } }, + { + type: 'package', + value: { + unique_identifier: 'pkg-uid', + manifest: { + plugin_unique_identifier: 'pkg-uid', + version: '1.0.0', + author: 'author', + icon: 'icon.png', + name: 'Package', + category: PluginCategoryEnum.tool, + label: {} as Record, + description: {} as Record, + created_at: '', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], + }, + }, + }, + ] + + render() + + expect(screen.getByTestId('plugins-count')).toHaveTextContent('3') + }) + + it('should handle rapid step changes', () => { + render() + + // Rapid step changes + fireEvent.click(screen.getByTestId('change-to-installed')) + fireEvent.click(screen.getByTestId('change-to-upload-failed')) + fireEvent.click(screen.getByTestId('change-to-ready')) + + // Should end up at readyToInstall + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall) + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should handle multiple setIsInstalling calls', () => { + render() + + fireEvent.click(screen.getByTestId('set-installing-true')) + fireEvent.click(screen.getByTestId('set-installing-false')) + fireEvent.click(screen.getByTestId('set-installing-true')) + + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledTimes(3) + expect(mockHideLogicState.setIsInstalling).toHaveBeenNthCalledWith(1, true) + expect(mockHideLogicState.setIsInstalling).toHaveBeenNthCalledWith(2, false) + expect(mockHideLogicState.setIsInstalling).toHaveBeenNthCalledWith(3, true) + }) + }) + + // ================================ + // InstallType Enum Tests + // ================================ + describe('InstallType Enum', () => { + it('should export InstallType enum with correct values', () => { + expect(InstallType.fromLocal).toBe('fromLocal') + expect(InstallType.fromMarketplace).toBe('fromMarketplace') + expect(InstallType.fromDSL).toBe('fromDSL') + }) + + it('should handle all InstallType values', () => { + const types = [InstallType.fromLocal, InstallType.fromMarketplace, InstallType.fromDSL] + + types.forEach((type) => { + const { unmount } = render( + , + ) + expect(screen.getByTestId('ready-to-install')).toBeInTheDocument() + unmount() + }) + }) + }) + + // ================================ + // Modal Integration Tests + // ================================ + describe('Modal Integration', () => { + it('should render modal with title', () => { + render() + + // Verify modal renders with title + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should render modal with closable behavior', () => { + render() + + // Modal should render the content including the ReadyToInstall component + expect(screen.getByTestId('ready-to-install')).toBeInTheDocument() + }) + + it('should display title in modal header', () => { + render() + + const titleElement = screen.getByText('plugin.installModal.installPlugin') + expect(titleElement).toBeInTheDocument() + expect(titleElement).toHaveClass('title-2xl-semi-bold') + }) + }) + + // ================================ + // Initial Step Determination Tests + // ================================ + describe('Initial Step Determination', () => { + it('should set initial step based on installType for fromMarketplace', () => { + render() + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall) + }) + + it('should set initial step based on installType for fromLocal', () => { + render() + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.uploading) + }) + + it('should set initial step based on installType for fromDSL', () => { + render() + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.uploading) + }) + + it('should use default installType when not provided', () => { + render() + + // Default is fromMarketplace which results in readyToInstall + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall) + }) + }) + + // ================================ + // useHideLogic Hook Integration Tests + // ================================ + describe('useHideLogic Hook Integration', () => { + it('should receive modalClassName from useHideLogic', () => { + mockHideLogicState.modalClassName = 'custom-modal-class' + + render() + + // Verify hook provides modalClassName (component uses it in Modal className prop) + expect(mockHideLogicState.modalClassName).toBe('custom-modal-class') + }) + + it('should pass onClose to useHideLogic', () => { + const onClose = vi.fn() + render() + + // The hook receives onClose and returns foldAnimInto + // When modal closes, foldAnimInto should be used + expect(mockHideLogicState.foldAnimInto).toBeDefined() + }) + + it('should use foldAnimInto for modal close action', () => { + render() + + // The modal's onClose is set to foldAnimInto + // This is verified by checking that the hook returns the function + expect(typeof mockHideLogicState.foldAnimInto).toBe('function') + }) + }) + + // ================================ + // ReadyToInstall Props Passing Tests + // ================================ + describe('ReadyToInstall Props Passing', () => { + it('should pass step to ReadyToInstall', () => { + render() + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall) + }) + + it('should pass onStepChange to ReadyToInstall', () => { + render() + + // Trigger step change + fireEvent.click(screen.getByTestId('change-to-installed')) + + expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.installed) + }) + + it('should pass onStartToInstall to ReadyToInstall', () => { + render() + + fireEvent.click(screen.getByTestId('start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalled() + }) + + it('should pass setIsInstalling to ReadyToInstall', () => { + render() + + fireEvent.click(screen.getByTestId('set-installing-true')) + + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(true) + }) + + it('should pass allPlugins (fromDSLPayload) to ReadyToInstall', () => { + const plugins = createMockDependencies() + render() + + expect(screen.getByTestId('plugins-count')).toHaveTextContent(String(plugins.length)) + }) + + it('should pass onClose to ReadyToInstall', () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByTestId('close-btn')) + + expect(onClose).toHaveBeenCalled() + }) + }) + + // ================================ + // Callback Memoization Tests + // ================================ + describe('Callback Memoization (getTitle)', () => { + it('should return correct title based on current step', () => { + render() + + // Default step (readyToInstall) -> installPlugin title + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should update title when step changes', () => { + render() + + // Change to installed + fireEvent.click(screen.getByTestId('change-to-installed')) + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + + // Change to uploadFailed + fireEvent.click(screen.getByTestId('change-to-upload-failed')) + expect(screen.getByText('plugin.installModal.uploadFailed')).toBeInTheDocument() + + // Change back to readyToInstall + fireEvent.click(screen.getByTestId('change-to-ready')) + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + }) + + // ================================ + // Error Handling Tests + // ================================ + describe('Error Handling', () => { + it('should handle null in fromDSLPayload gracefully', () => { + // TypeScript would catch this, but testing runtime behavior + // @ts-expect-error Testing null handling + render() + + // Should render without crashing, count will be 0 + expect(screen.getByTestId('plugins-count')).toHaveTextContent('0') + }) + + it('should handle undefined in fromDSLPayload gracefully', () => { + // @ts-expect-error Testing undefined handling + render() + + // Should render without crashing + expect(screen.getByTestId('plugins-count')).toHaveTextContent('0') + }) + }) + + // ================================ + // CSS Classes Tests + // ================================ + describe('CSS Classes', () => { + it('should render modal with proper structure', () => { + render() + + // Verify component renders with expected structure + expect(screen.getByTestId('ready-to-install')).toBeInTheDocument() + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should apply correct CSS classes to title', () => { + render() + + const title = screen.getByText('plugin.installModal.installPlugin') + expect(title).toHaveClass('title-2xl-semi-bold') + expect(title).toHaveClass('text-text-primary') + }) + }) + + // ================================ + // Rendering Consistency Tests + // ================================ + describe('Rendering Consistency', () => { + it('should render consistently across different installTypes', () => { + // fromMarketplace + const { unmount: unmount1 } = render( + , + ) + expect(screen.getByTestId('ready-to-install')).toBeInTheDocument() + unmount1() + + // fromLocal + const { unmount: unmount2 } = render( + , + ) + expect(screen.getByTestId('ready-to-install')).toBeInTheDocument() + unmount2() + + // fromDSL + const { unmount: unmount3 } = render( + , + ) + expect(screen.getByTestId('ready-to-install')).toBeInTheDocument() + unmount3() + }) + + it('should maintain modal structure across step changes', () => { + render() + + // Check ReadyToInstall component exists + expect(screen.getByTestId('ready-to-install')).toBeInTheDocument() + + // Change step + fireEvent.click(screen.getByTestId('change-to-installed')) + + // ReadyToInstall should still exist + expect(screen.getByTestId('ready-to-install')).toBeInTheDocument() + // Title should be updated + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + }) + }) +}) + +// ================================================================ +// ReadyToInstall Component Tests (using mocked version from InstallBundle) +// ================================================================ +describe('ReadyToInstall (via InstallBundle mock)', () => { + // Note: ReadyToInstall is mocked for InstallBundle tests. + // These tests verify the mock interface and component behavior. + + beforeEach(() => { + vi.clearAllMocks() + }) + + // ================================ + // Component Definition Tests + // ================================ + describe('Component Definition', () => { + it('should be defined and importable', () => { + expect(ReadyToInstall).toBeDefined() + }) + + it('should be a memoized component', () => { + // The import gives us the mocked version, which is a function + expect(typeof ReadyToInstall).toBe('function') + }) + }) +}) + +// ================================================================ +// Installed Component Tests +// ================================================================ +describe('Installed', () => { + const defaultInstalledProps = { + list: [createMockPlugin()], + installStatus: [createMockInstallStatus()], + onCancel: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render plugin list', () => { + render() + + // Should show close button + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() + }) + + it('should render multiple plugins', () => { + const plugins = [ + createMockPlugin({ plugin_id: 'plugin-1', name: 'Plugin 1' }), + createMockPlugin({ plugin_id: 'plugin-2', name: 'Plugin 2' }), + ] + const statuses = [ + createMockInstallStatus({ success: true }), + createMockInstallStatus({ success: false }), + ] + + render() + + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() + }) + + it('should not render close button when isHideButton is true', () => { + render() + + expect(screen.queryByRole('button', { name: 'common.operation.close' })).not.toBeInTheDocument() + }) + }) + + // ================================ + // User Interactions Tests + // ================================ + describe('User Interactions', () => { + it('should call onCancel when close button is clicked', () => { + const onCancel = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty plugin list', () => { + render() + + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() + }) + + it('should handle mixed install statuses', () => { + const plugins = [ + createMockPlugin({ plugin_id: 'success-plugin' }), + createMockPlugin({ plugin_id: 'failed-plugin' }), + ] + const statuses = [ + createMockInstallStatus({ success: true }), + createMockInstallStatus({ success: false }), + ] + + render() + + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() + }) + }) + + // ================================ + // Component Memoization Tests + // ================================ + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(Installed).toBeDefined() + expect(typeof Installed).toBe('object') + }) + }) +}) + +// ================================================================ +// LoadedItem Component Tests +// ================================================================ +describe('LoadedItem', () => { + const defaultLoadedItemProps = { + checked: false, + onCheckedChange: vi.fn(), + payload: createMockPlugin(), + versionInfo: createMockVersionProps(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Helper to find checkbox element + const getCheckbox = () => screen.getByTestId(/^checkbox/) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render checkbox', () => { + render() + + expect(getCheckbox()).toBeInTheDocument() + }) + + it('should render checkbox with check icon when checked prop is true', () => { + render() + + expect(getCheckbox()).toBeInTheDocument() + // Check icon should be present when checked + expect(screen.getByTestId(/^check-icon/)).toBeInTheDocument() + }) + + it('should render checkbox without check icon when checked prop is false', () => { + render() + + expect(getCheckbox()).toBeInTheDocument() + // Check icon should not be present when unchecked + expect(screen.queryByTestId(/^check-icon/)).not.toBeInTheDocument() + }) + }) + + // ================================ + // User Interactions Tests + // ================================ + describe('User Interactions', () => { + it('should call onCheckedChange when checkbox is clicked', () => { + const onCheckedChange = vi.fn() + render() + + fireEvent.click(getCheckbox()) + + expect(onCheckedChange).toHaveBeenCalledWith(defaultLoadedItemProps.payload) + }) + }) + + // ================================ + // Props Tests + // ================================ + describe('Props', () => { + it('should handle isFromMarketPlace prop', () => { + render() + + expect(getCheckbox()).toBeInTheDocument() + }) + + it('should display version info when payload has version', () => { + const pluginWithVersion = createMockPlugin({ version: '2.0.0' }) + render() + + expect(getCheckbox()).toBeInTheDocument() + }) + }) + + // ================================ + // Component Memoization Tests + // ================================ + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(LoadedItem).toBeDefined() + expect(typeof LoadedItem).toBe('object') + }) + }) +}) + +// ================================================================ +// MarketplaceItem Component Tests +// ================================================================ +describe('MarketplaceItem', () => { + const defaultMarketplaceItemProps = { + checked: false, + onCheckedChange: vi.fn(), + payload: createMockPlugin(), + version: '1.0.0', + versionInfo: createMockVersionProps(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Helper to find checkbox element + const getCheckbox = () => screen.getByTestId(/^checkbox/) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render LoadedItem when payload is provided', () => { + render() + + expect(getCheckbox()).toBeInTheDocument() + }) + + it('should render Loading when payload is undefined', () => { + render() + + // Loading component renders a disabled checkbox + const checkbox = screen.getByTestId(/^checkbox/) + expect(checkbox).toHaveClass('cursor-not-allowed') + }) + }) + + // ================================ + // Props Tests + // ================================ + describe('Props', () => { + it('should pass version to LoadedItem', () => { + render() + + expect(getCheckbox()).toBeInTheDocument() + }) + + it('should pass checked state to LoadedItem', () => { + render() + + // When checked, the check icon should be present + expect(screen.getByTestId(/^check-icon/)).toBeInTheDocument() + }) + }) + + // ================================ + // User Interactions Tests + // ================================ + describe('User Interactions', () => { + it('should call onCheckedChange when clicked', () => { + const onCheckedChange = vi.fn() + render() + + fireEvent.click(getCheckbox()) + + expect(onCheckedChange).toHaveBeenCalled() + }) + }) + + // ================================ + // Component Memoization Tests + // ================================ + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(MarketplaceItem).toBeDefined() + expect(typeof MarketplaceItem).toBe('object') + }) + }) +}) + +// ================================================================ +// PackageItem Component Tests +// ================================================================ +describe('PackageItem', () => { + const defaultPackageItemProps = { + checked: false, + onCheckedChange: vi.fn(), + payload: createMockPackageDependency(), + versionInfo: createMockVersionProps(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Helper to find checkbox element + const getCheckbox = () => screen.getByTestId(/^checkbox/) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render LoadedItem when payload has manifest', () => { + render() + + expect(getCheckbox()).toBeInTheDocument() + }) + + it('should render LoadingError when manifest is missing', () => { + const invalidPayload = { + type: 'package', + value: { unique_identifier: 'test' }, + } as PackageDependency + + render() + + // LoadingError renders a disabled checkbox and error text + const checkbox = screen.getByTestId(/^checkbox/) + expect(checkbox).toHaveClass('cursor-not-allowed') + expect(screen.getByText('plugin.installModal.pluginLoadError')).toBeInTheDocument() + }) + }) + + // ================================ + // Props Tests + // ================================ + describe('Props', () => { + it('should pass isFromMarketPlace to LoadedItem', () => { + render() + + expect(getCheckbox()).toBeInTheDocument() + }) + + it('should pass checked state to LoadedItem', () => { + render() + + // When checked, the check icon should be present + expect(screen.getByTestId(/^check-icon/)).toBeInTheDocument() + }) + }) + + // ================================ + // User Interactions Tests + // ================================ + describe('User Interactions', () => { + it('should call onCheckedChange when clicked', () => { + const onCheckedChange = vi.fn() + render() + + fireEvent.click(getCheckbox()) + + expect(onCheckedChange).toHaveBeenCalled() + }) + }) + + // ================================ + // Component Memoization Tests + // ================================ + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(PackageItem).toBeDefined() + expect(typeof PackageItem).toBe('object') + }) + }) +}) + +// ================================================================ +// GithubItem Component Tests +// ================================================================ +describe('GithubItem', () => { + const defaultGithubItemProps = { + checked: false, + onCheckedChange: vi.fn(), + dependency: createMockGitHubDependency(), + versionInfo: createMockVersionProps(), + onFetchedPayload: vi.fn(), + onFetchError: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockUseUploadGitHub.mockReturnValue({ data: null, error: null }) + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render Loading when data is not yet fetched', () => { + mockUseUploadGitHub.mockReturnValue({ data: null, error: null }) + render() + + // Loading component renders a disabled checkbox + const checkbox = screen.getByTestId(/^checkbox/) + expect(checkbox).toHaveClass('cursor-not-allowed') + }) + + it('should render LoadedItem when data is fetched', async () => { + const mockData = { + unique_identifier: 'test-uid', + manifest: { + plugin_unique_identifier: 'test-uid', + version: '1.0.0', + author: 'test-author', + icon: 'icon.png', + name: 'Test Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Test' }, + description: { 'en-US': 'Test Description' }, + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {}, + }, + } + mockUseUploadGitHub.mockReturnValue({ data: mockData, error: null }) + + render() + + // When data is loaded, LoadedItem should be rendered with checkbox + await waitFor(() => { + expect(screen.getByTestId(/^checkbox/)).toBeInTheDocument() + }) + }) + }) + + // ================================ + // Callback Tests + // ================================ + describe('Callbacks', () => { + it('should call onFetchedPayload when data is fetched', async () => { + const onFetchedPayload = vi.fn() + const mockData = { + unique_identifier: 'test-uid', + manifest: { + plugin_unique_identifier: 'test-uid', + version: '1.0.0', + author: 'test-author', + icon: 'icon.png', + name: 'Test Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Test' }, + description: { 'en-US': 'Test Description' }, + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {}, + }, + } + mockUseUploadGitHub.mockReturnValue({ data: mockData, error: null }) + + render() + + await waitFor(() => { + expect(onFetchedPayload).toHaveBeenCalled() + }) + }) + + it('should call onFetchError when error occurs', async () => { + const onFetchError = vi.fn() + mockUseUploadGitHub.mockReturnValue({ data: null, error: new Error('Fetch failed') }) + + render() + + await waitFor(() => { + expect(onFetchError).toHaveBeenCalled() + }) + }) + }) + + // ================================ + // Props Tests + // ================================ + describe('Props', () => { + it('should pass dependency info to useUploadGitHub', () => { + const dependency = createMockGitHubDependency() + render() + + expect(mockUseUploadGitHub).toHaveBeenCalledWith({ + repo: dependency.value.repo, + version: dependency.value.version, + package: dependency.value.package, + }) + }) + }) + + // ================================ + // Component Memoization Tests + // ================================ + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(GithubItem).toBeDefined() + expect(typeof GithubItem).toBe('object') + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-from-github/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/index.spec.tsx new file mode 100644 index 0000000000..5266f810f1 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-from-github/index.spec.tsx @@ -0,0 +1,2136 @@ +import type { GitHubRepoReleaseResponse, PluginDeclaration, PluginManifestInMarket, UpdateFromGitHubPayload } from '../../types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '../../types' +import { convertRepoToUrl, parseGitHubUrl, pluginManifestInMarketToPluginProps, pluginManifestToCardPluginProps } from '../utils' +import InstallFromGitHub from './index' + +// Factory functions for test data (defined before mocks that use them) +const createMockManifest = (overrides: Partial = {}): PluginDeclaration => ({ + plugin_unique_identifier: 'test-plugin-uid', + version: '1.0.0', + author: 'test-author', + icon: 'test-icon.png', + name: 'Test Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'], + description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'], + created_at: '2024-01-01T00:00:00Z', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], + ...overrides, +}) + +const createMockReleases = (): GitHubRepoReleaseResponse[] => [ + { + tag_name: 'v1.0.0', + assets: [ + { id: 1, name: 'plugin-v1.0.0.zip', browser_download_url: 'https://github.com/test/repo/releases/download/v1.0.0/plugin-v1.0.0.zip' }, + { id: 2, name: 'plugin-v1.0.0.tar.gz', browser_download_url: 'https://github.com/test/repo/releases/download/v1.0.0/plugin-v1.0.0.tar.gz' }, + ], + }, + { + tag_name: 'v0.9.0', + assets: [ + { id: 3, name: 'plugin-v0.9.0.zip', browser_download_url: 'https://github.com/test/repo/releases/download/v0.9.0/plugin-v0.9.0.zip' }, + ], + }, +] + +const createUpdatePayload = (overrides: Partial = {}): UpdateFromGitHubPayload => ({ + originalPackageInfo: { + id: 'original-id', + repo: 'owner/repo', + version: 'v0.9.0', + package: 'plugin-v0.9.0.zip', + releases: createMockReleases(), + }, + ...overrides, +}) + +// Mock external dependencies +const mockNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: (props: { type: string, message: string }) => mockNotify(props), + }, +})) + +const mockGetIconUrl = vi.fn() +vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({ + default: () => ({ getIconUrl: mockGetIconUrl }), +})) + +const mockFetchReleases = vi.fn() +vi.mock('../hooks', () => ({ + useGitHubReleases: () => ({ fetchReleases: mockFetchReleases }), +})) + +const mockRefreshPluginList = vi.fn() +vi.mock('../hooks/use-refresh-plugin-list', () => ({ + default: () => ({ refreshPluginList: mockRefreshPluginList }), +})) + +let mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), +} +vi.mock('../hooks/use-hide-logic', () => ({ + default: () => mockHideLogicState, +})) + +// Mock child components +vi.mock('./steps/setURL', () => ({ + default: ({ repoUrl, onChange, onNext, onCancel }: { + repoUrl: string + onChange: (value: string) => void + onNext: () => void + onCancel: () => void + }) => ( +
+ onChange(e.target.value)} + /> + + +
+ ), +})) + +vi.mock('./steps/selectPackage', () => ({ + default: ({ + repoUrl, + selectedVersion, + versions, + onSelectVersion, + selectedPackage, + packages, + onSelectPackage, + onUploaded, + onFailed, + onBack, + }: { + repoUrl: string + selectedVersion: string + versions: { value: string, name: string }[] + onSelectVersion: (item: { value: string, name: string }) => void + selectedPackage: string + packages: { value: string, name: string }[] + onSelectPackage: (item: { value: string, name: string }) => void + onUploaded: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void + onFailed: (errorMsg: string) => void + onBack: () => void + }) => ( +
+ {repoUrl} + {selectedVersion} + {selectedPackage} + {versions.length} + {packages.length} + + + + + +
+ ), +})) + +vi.mock('./steps/loaded', () => ({ + default: ({ + uniqueIdentifier, + payload, + repoUrl, + selectedVersion, + selectedPackage, + onBack, + onStartToInstall, + onInstalled, + onFailed, + }: { + uniqueIdentifier: string + payload: PluginDeclaration + repoUrl: string + selectedVersion: string + selectedPackage: string + onBack: () => void + onStartToInstall: () => void + onInstalled: (notRefresh?: boolean) => void + onFailed: (message?: string) => void + }) => ( +
+ {uniqueIdentifier} + {payload?.name} + {repoUrl} + {selectedVersion} + {selectedPackage} + + + + + + +
+ ), +})) + +vi.mock('../base/installed', () => ({ + default: ({ payload, isFailed, errMsg, onCancel }: { + payload: PluginDeclaration | null + isFailed: boolean + errMsg: string | null + onCancel: () => void + }) => ( +
+ {payload?.name || 'no-payload'} + {isFailed ? 'true' : 'false'} + {errMsg || 'no-error'} + +
+ ), +})) + +describe('InstallFromGitHub', () => { + const defaultProps = { + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetIconUrl.mockResolvedValue('processed-icon-url') + mockFetchReleases.mockResolvedValue(createMockReleases()) + mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), + } + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render modal with correct initial state for new installation', () => { + render() + + expect(screen.getByTestId('set-url-step')).toBeInTheDocument() + expect(screen.getByTestId('repo-url-input')).toHaveValue('') + }) + + it('should render modal with selectPackage step when updatePayload is provided', () => { + const updatePayload = createUpdatePayload() + + render() + + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + expect(screen.getByTestId('repo-url-display')).toHaveTextContent('https://github.com/owner/repo') + }) + + it('should render install note text in non-terminal steps', () => { + render() + + expect(screen.getByText('plugin.installFromGitHub.installNote')).toBeInTheDocument() + }) + + it('should apply modal className from useHideLogic', () => { + // Verify useHideLogic provides modalClassName + // The actual className application is handled by Modal component internally + // We verify the hook integration by checking that it returns the expected class + expect(mockHideLogicState.modalClassName).toBe('test-modal-class') + }) + }) + + // ================================ + // Title Tests + // ================================ + describe('Title Display', () => { + it('should show install title when no updatePayload', () => { + render() + + expect(screen.getByText('plugin.installFromGitHub.installPlugin')).toBeInTheDocument() + }) + + it('should show update title when updatePayload is provided', () => { + render() + + expect(screen.getByText('plugin.installFromGitHub.updatePlugin')).toBeInTheDocument() + }) + }) + + // ================================ + // State Management Tests + // ================================ + describe('State Management', () => { + it('should update repoUrl when user types in input', () => { + render() + + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/test/repo' } }) + + expect(input).toHaveValue('https://github.com/test/repo') + }) + + it('should transition from setUrl to selectPackage on successful URL submit', async () => { + render() + + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } }) + + const nextBtn = screen.getByTestId('next-btn') + fireEvent.click(nextBtn) + + await waitFor(() => { + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + }) + }) + + it('should update selectedVersion when version is selected', async () => { + render() + + const selectVersionBtn = screen.getByTestId('select-version-btn') + fireEvent.click(selectVersionBtn) + + expect(screen.getByTestId('selected-version')).toHaveTextContent('v1.0.0') + }) + + it('should update selectedPackage when package is selected', async () => { + render() + + const selectPackageBtn = screen.getByTestId('select-package-btn') + fireEvent.click(selectPackageBtn) + + expect(screen.getByTestId('selected-package')).toHaveTextContent('package.zip') + }) + + it('should transition to readyToInstall step after successful upload', async () => { + render() + + const uploadBtn = screen.getByTestId('trigger-upload-btn') + fireEvent.click(uploadBtn) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + }) + + it('should transition to installed step after successful install', async () => { + render() + + // First upload + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + // Then install + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('false') + }) + }) + + it('should transition to installFailed step on install failure', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + expect(screen.getByTestId('error-msg')).toHaveTextContent('Install failed') + }) + }) + + it('should transition to uploadFailed step on upload failure', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + expect(screen.getByTestId('error-msg')).toHaveTextContent('Upload failed error') + }) + }) + }) + + // ================================ + // Versions and Packages Tests + // ================================ + describe('Versions and Packages Computation', () => { + it('should derive versions from releases', () => { + render() + + expect(screen.getByTestId('versions-count')).toHaveTextContent('2') + }) + + it('should derive packages from selected version', async () => { + render() + + // Initially no packages (no version selected) + expect(screen.getByTestId('packages-count')).toHaveTextContent('0') + + // Select a version + fireEvent.click(screen.getByTestId('select-version-btn')) + + await waitFor(() => { + expect(screen.getByTestId('packages-count')).toHaveTextContent('2') + }) + }) + }) + + // ================================ + // URL Validation Tests + // ================================ + describe('URL Validation', () => { + it('should show error toast for invalid GitHub URL', async () => { + render() + + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'invalid-url' } }) + + const nextBtn = screen.getByTestId('next-btn') + fireEvent.click(nextBtn) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'plugin.error.inValidGitHubUrl', + }) + }) + }) + + it('should show error toast when no releases are found', async () => { + mockFetchReleases.mockResolvedValue([]) + + render() + + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } }) + + const nextBtn = screen.getByTestId('next-btn') + fireEvent.click(nextBtn) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'plugin.error.noReleasesFound', + }) + }) + }) + + it('should show error toast when fetchReleases throws', async () => { + mockFetchReleases.mockRejectedValue(new Error('Network error')) + + render() + + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } }) + + const nextBtn = screen.getByTestId('next-btn') + fireEvent.click(nextBtn) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'plugin.error.fetchReleasesError', + }) + }) + }) + }) + + // ================================ + // Back Navigation Tests + // ================================ + describe('Back Navigation', () => { + it('should go back from selectPackage to setUrl', async () => { + render() + + // Navigate to selectPackage + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } }) + fireEvent.click(screen.getByTestId('next-btn')) + + await waitFor(() => { + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + }) + + // Go back + fireEvent.click(screen.getByTestId('back-btn')) + + await waitFor(() => { + expect(screen.getByTestId('set-url-step')).toBeInTheDocument() + }) + }) + + it('should go back from readyToInstall to selectPackage', async () => { + render() + + // Navigate to readyToInstall + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + // Go back + fireEvent.click(screen.getByTestId('loaded-back-btn')) + + await waitFor(() => { + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + }) + }) + }) + + // ================================ + // Callback Tests + // ================================ + describe('Callbacks', () => { + it('should call onClose when cancel button is clicked', () => { + render() + + fireEvent.click(screen.getByTestId('cancel-btn')) + + expect(defaultProps.onClose).toHaveBeenCalledTimes(1) + }) + + it('should call foldAnimInto when modal close is triggered', () => { + render() + + // The modal's onClose is bound to foldAnimInto + // We verify the hook is properly connected + expect(mockHideLogicState.foldAnimInto).toBeDefined() + }) + + it('should call onSuccess when installation completes', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(defaultProps.onSuccess).toHaveBeenCalledTimes(1) + }) + }) + + it('should call refreshPluginList when installation completes without notRefresh flag', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(mockRefreshPluginList).toHaveBeenCalled() + }) + }) + + it('should not call refreshPluginList when notRefresh flag is true', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-success-no-refresh-btn')) + + await waitFor(() => { + expect(mockRefreshPluginList).not.toHaveBeenCalled() + }) + }) + + it('should call setIsInstalling(false) when installation completes', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + + it('should call handleStartToInstall when start install is triggered', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1) + }) + + it('should call setIsInstalling(false) when installation fails', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + }) + + // ================================ + // Callback Stability Tests (Memoization) + // ================================ + describe('Callback Stability', () => { + it('should maintain stable handleUploadFail callback reference', async () => { + const { rerender } = render() + + const firstRender = screen.getByTestId('select-package-step') + expect(firstRender).toBeInTheDocument() + + // Rerender with same props + rerender() + + // The component should still work correctly + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + }) + }) + + // ================================ + // Icon Processing Tests + // ================================ + describe('Icon Processing', () => { + it('should process icon URL on successful upload', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(mockGetIconUrl).toHaveBeenCalled() + }) + }) + + it('should handle icon processing error gracefully', async () => { + mockGetIconUrl.mockRejectedValue(new Error('Icon processing failed')) + + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + }) + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty releases array from updatePayload', () => { + const updatePayload = createUpdatePayload({ + originalPackageInfo: { + id: 'original-id', + repo: 'owner/repo', + version: 'v0.9.0', + package: 'plugin.zip', + releases: [], + }, + }) + + render() + + expect(screen.getByTestId('versions-count')).toHaveTextContent('0') + }) + + it('should handle release with no assets', async () => { + const updatePayload = createUpdatePayload({ + originalPackageInfo: { + id: 'original-id', + repo: 'owner/repo', + version: 'v0.9.0', + package: 'plugin.zip', + releases: [{ tag_name: 'v1.0.0', assets: [] }], + }, + }) + + render() + + // Select the version + fireEvent.click(screen.getByTestId('select-version-btn')) + + // Should have 0 packages + expect(screen.getByTestId('packages-count')).toHaveTextContent('0') + }) + + it('should handle selected version not found in releases', async () => { + const updatePayload = createUpdatePayload({ + originalPackageInfo: { + id: 'original-id', + repo: 'owner/repo', + version: 'v0.9.0', + package: 'plugin.zip', + releases: [], + }, + }) + + render() + + fireEvent.click(screen.getByTestId('select-version-btn')) + + expect(screen.getByTestId('packages-count')).toHaveTextContent('0') + }) + + it('should handle install failure without error message', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-fail-no-msg-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + expect(screen.getByTestId('error-msg')).toHaveTextContent('no-error') + }) + }) + + it('should handle URL without trailing slash', async () => { + render() + + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } }) + + fireEvent.click(screen.getByTestId('next-btn')) + + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo') + }) + }) + + it('should preserve state correctly through step transitions', async () => { + render() + + // Set URL + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/test/myrepo' } }) + + // Navigate to selectPackage + fireEvent.click(screen.getByTestId('next-btn')) + + await waitFor(() => { + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + }) + + // Verify URL is preserved + expect(screen.getByTestId('repo-url-display')).toHaveTextContent('https://github.com/test/myrepo') + + // Select version and package + fireEvent.click(screen.getByTestId('select-version-btn')) + fireEvent.click(screen.getByTestId('select-package-btn')) + + // Navigate to readyToInstall + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + // Verify all data is preserved + expect(screen.getByTestId('loaded-repo-url')).toHaveTextContent('https://github.com/test/myrepo') + expect(screen.getByTestId('loaded-version')).toHaveTextContent('v1.0.0') + expect(screen.getByTestId('loaded-package')).toHaveTextContent('package.zip') + }) + }) + + // ================================ + // Terminal Steps Rendering Tests + // ================================ + describe('Terminal Steps Rendering', () => { + it('should render Installed component for installed step', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.queryByText('plugin.installFromGitHub.installNote')).not.toBeInTheDocument() + }) + }) + + it('should render Installed component for uploadFailed step', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + }) + }) + + it('should render Installed component for installFailed step', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + }) + }) + + it('should call onClose when close button is clicked in installed step', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('installed-close-btn')) + + expect(defaultProps.onClose).toHaveBeenCalledTimes(1) + }) + }) + + // ================================ + // Title Update Tests + // ================================ + describe('Title Updates', () => { + it('should show success title when installed', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByText('plugin.installFromGitHub.installedSuccessfully')).toBeInTheDocument() + }) + }) + + it('should show failed title when install failed', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByText('plugin.installFromGitHub.installFailed')).toBeInTheDocument() + }) + }) + }) + + // ================================ + // Data Flow Tests + // ================================ + describe('Data Flow', () => { + it('should pass correct uniqueIdentifier to Loaded component', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('unique-identifier')).toHaveTextContent('test-unique-id') + }) + }) + + it('should pass processed manifest to Loaded component', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('payload-name')).toHaveTextContent('Test Plugin') + }) + }) + + it('should pass manifest with processed icon to Loaded component', async () => { + mockGetIconUrl.mockResolvedValue('https://processed-icon.com/icon.png') + + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(mockGetIconUrl).toHaveBeenCalledWith('test-icon.png') + }) + }) + }) + + // ================================ + // Prop Variations Tests + // ================================ + describe('Prop Variations', () => { + it('should work without updatePayload (fresh install flow)', async () => { + render() + + // Start from setUrl step + expect(screen.getByTestId('set-url-step')).toBeInTheDocument() + + // Enter URL + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } }) + fireEvent.click(screen.getByTestId('next-btn')) + + await waitFor(() => { + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + }) + }) + + it('should work with updatePayload (update flow)', async () => { + const updatePayload = createUpdatePayload() + + render() + + // Start from selectPackage step + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + expect(screen.getByTestId('repo-url-display')).toHaveTextContent('https://github.com/owner/repo') + }) + + it('should use releases from updatePayload', () => { + const customReleases: GitHubRepoReleaseResponse[] = [ + { tag_name: 'v2.0.0', assets: [{ id: 1, name: 'custom.zip', browser_download_url: 'url' }] }, + { tag_name: 'v1.5.0', assets: [{ id: 2, name: 'custom2.zip', browser_download_url: 'url2' }] }, + { tag_name: 'v1.0.0', assets: [{ id: 3, name: 'custom3.zip', browser_download_url: 'url3' }] }, + ] + + const updatePayload = createUpdatePayload({ + originalPackageInfo: { + id: 'id', + repo: 'owner/repo', + version: 'v1.0.0', + package: 'pkg.zip', + releases: customReleases, + }, + }) + + render() + + expect(screen.getByTestId('versions-count')).toHaveTextContent('3') + }) + + it('should convert repo to URL correctly', () => { + const updatePayload = createUpdatePayload({ + originalPackageInfo: { + id: 'id', + repo: 'myorg/myrepo', + version: 'v1.0.0', + package: 'pkg.zip', + releases: createMockReleases(), + }, + }) + + render() + + expect(screen.getByTestId('repo-url-display')).toHaveTextContent('https://github.com/myorg/myrepo') + }) + }) + + // ================================ + // Error Handling Tests + // ================================ + describe('Error Handling', () => { + it('should handle API error with response message', async () => { + mockGetIconUrl.mockRejectedValue({ + response: { message: 'API Error Message' }, + }) + + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + expect(screen.getByTestId('error-msg')).toHaveTextContent('API Error Message') + }) + }) + + it('should handle API error without response message', async () => { + mockGetIconUrl.mockRejectedValue(new Error('Generic error')) + + render() + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + expect(screen.getByTestId('error-msg')).toHaveTextContent('plugin.installModal.installFailedDesc') + }) + }) + }) + + // ================================ + // handleBack Default Case Tests + // ================================ + describe('handleBack Edge Cases', () => { + it('should not change state when back is called from setUrl step', async () => { + // This tests the default case in handleBack switch + // When in setUrl step, calling back should keep the state unchanged + render() + + // Verify we're on setUrl step + expect(screen.getByTestId('set-url-step')).toBeInTheDocument() + + // The setUrl step doesn't expose onBack in the real component, + // but our mock doesn't have it either - this is correct behavior + // as setUrl is the first step with no back option + }) + + it('should handle multiple back navigations correctly', async () => { + render() + + // Navigate to selectPackage + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } }) + fireEvent.click(screen.getByTestId('next-btn')) + + await waitFor(() => { + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + }) + + // Navigate to readyToInstall + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + // Go back to selectPackage + fireEvent.click(screen.getByTestId('loaded-back-btn')) + + await waitFor(() => { + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + }) + + // Go back to setUrl + fireEvent.click(screen.getByTestId('back-btn')) + + await waitFor(() => { + expect(screen.getByTestId('set-url-step')).toBeInTheDocument() + }) + + // Verify URL is preserved after back navigation + expect(screen.getByTestId('repo-url-input')).toHaveValue('https://github.com/owner/repo') + }) + }) +}) + +// ================================ +// Utility Functions Tests +// ================================ +describe('Install Plugin Utils', () => { + describe('parseGitHubUrl', () => { + it('should parse valid GitHub URL correctly', () => { + const result = parseGitHubUrl('https://github.com/owner/repo') + + expect(result.isValid).toBe(true) + expect(result.owner).toBe('owner') + expect(result.repo).toBe('repo') + }) + + it('should parse GitHub URL with trailing slash', () => { + const result = parseGitHubUrl('https://github.com/owner/repo/') + + expect(result.isValid).toBe(true) + expect(result.owner).toBe('owner') + expect(result.repo).toBe('repo') + }) + + it('should return invalid for non-GitHub URL', () => { + const result = parseGitHubUrl('https://gitlab.com/owner/repo') + + expect(result.isValid).toBe(false) + expect(result.owner).toBeUndefined() + expect(result.repo).toBeUndefined() + }) + + it('should return invalid for malformed URL', () => { + const result = parseGitHubUrl('not-a-url') + + expect(result.isValid).toBe(false) + }) + + it('should return invalid for GitHub URL with extra path segments', () => { + const result = parseGitHubUrl('https://github.com/owner/repo/tree/main') + + expect(result.isValid).toBe(false) + }) + + it('should return invalid for empty string', () => { + const result = parseGitHubUrl('') + + expect(result.isValid).toBe(false) + }) + + it('should handle URL with special characters in owner/repo names', () => { + const result = parseGitHubUrl('https://github.com/my-org/my-repo-123') + + expect(result.isValid).toBe(true) + expect(result.owner).toBe('my-org') + expect(result.repo).toBe('my-repo-123') + }) + }) + + describe('convertRepoToUrl', () => { + it('should convert repo string to full GitHub URL', () => { + const result = convertRepoToUrl('owner/repo') + + expect(result).toBe('https://github.com/owner/repo') + }) + + it('should return empty string for empty repo', () => { + const result = convertRepoToUrl('') + + expect(result).toBe('') + }) + + it('should handle repo with organization name', () => { + const result = convertRepoToUrl('my-organization/my-repository') + + expect(result).toBe('https://github.com/my-organization/my-repository') + }) + }) + + describe('pluginManifestToCardPluginProps', () => { + it('should convert PluginDeclaration to Plugin props correctly', () => { + const manifest: PluginDeclaration = { + plugin_unique_identifier: 'test-uid', + version: '1.0.0', + author: 'test-author', + icon: 'icon.png', + icon_dark: 'icon-dark.png', + name: 'Test Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Test Label' } as PluginDeclaration['label'], + description: { 'en-US': 'Test Description' } as PluginDeclaration['description'], + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: ['tag1', 'tag2'], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], + } + + const result = pluginManifestToCardPluginProps(manifest) + + expect(result.plugin_id).toBe('test-uid') + expect(result.type).toBe('tool') + expect(result.category).toBe(PluginCategoryEnum.tool) + expect(result.name).toBe('Test Plugin') + expect(result.version).toBe('1.0.0') + expect(result.latest_version).toBe('') + expect(result.org).toBe('test-author') + expect(result.author).toBe('test-author') + expect(result.icon).toBe('icon.png') + expect(result.icon_dark).toBe('icon-dark.png') + expect(result.verified).toBe(true) + expect(result.tags).toEqual([{ name: 'tag1' }, { name: 'tag2' }]) + expect(result.from).toBe('package') + }) + + it('should handle manifest with empty tags', () => { + const manifest: PluginDeclaration = { + plugin_unique_identifier: 'test-uid', + version: '1.0.0', + author: 'author', + icon: 'icon.png', + name: 'Plugin', + category: PluginCategoryEnum.model, + label: {} as PluginDeclaration['label'], + description: {} as PluginDeclaration['description'], + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: false, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], + } + + const result = pluginManifestToCardPluginProps(manifest) + + expect(result.tags).toEqual([]) + expect(result.verified).toBe(false) + }) + }) + + describe('pluginManifestInMarketToPluginProps', () => { + it('should convert PluginManifestInMarket to Plugin props correctly', () => { + const manifest: PluginManifestInMarket = { + plugin_unique_identifier: 'market-uid', + name: 'Market Plugin', + org: 'market-org', + icon: 'market-icon.png', + label: { 'en-US': 'Market Label' } as PluginManifestInMarket['label'], + category: PluginCategoryEnum.extension, + version: '1.0.0', + latest_version: '2.0.0', + brief: { 'en-US': 'Brief Description' } as PluginManifestInMarket['brief'], + introduction: 'Full introduction text', + verified: true, + install_count: 1000, + badges: ['featured', 'verified'], + verification: { authorized_category: 'partner' }, + from: 'marketplace', + } + + const result = pluginManifestInMarketToPluginProps(manifest) + + expect(result.plugin_id).toBe('market-uid') + expect(result.type).toBe('extension') + expect(result.name).toBe('Market Plugin') + expect(result.version).toBe('2.0.0') + expect(result.latest_version).toBe('2.0.0') + expect(result.org).toBe('market-org') + expect(result.introduction).toBe('Full introduction text') + expect(result.badges).toEqual(['featured', 'verified']) + expect(result.verification.authorized_category).toBe('partner') + expect(result.from).toBe('marketplace') + }) + + it('should use default verification when empty', () => { + const manifest: PluginManifestInMarket = { + plugin_unique_identifier: 'uid', + name: 'Plugin', + org: 'org', + icon: 'icon.png', + label: {} as PluginManifestInMarket['label'], + category: PluginCategoryEnum.tool, + version: '1.0.0', + latest_version: '1.0.0', + brief: {} as PluginManifestInMarket['brief'], + introduction: '', + verified: false, + install_count: 0, + badges: [], + verification: {} as PluginManifestInMarket['verification'], + from: 'github', + } + + const result = pluginManifestInMarketToPluginProps(manifest) + + expect(result.verification.authorized_category).toBe('langgenius') + expect(result.verified).toBe(true) // always true in this function + }) + + it('should handle marketplace plugin with from github source', () => { + const manifest: PluginManifestInMarket = { + plugin_unique_identifier: 'github-uid', + name: 'GitHub Plugin', + org: 'github-org', + icon: 'icon.png', + label: {} as PluginManifestInMarket['label'], + category: PluginCategoryEnum.agent, + version: '0.1.0', + latest_version: '0.2.0', + brief: {} as PluginManifestInMarket['brief'], + introduction: 'From GitHub', + verified: true, + install_count: 50, + badges: [], + verification: { authorized_category: 'community' }, + from: 'github', + } + + const result = pluginManifestInMarketToPluginProps(manifest) + + expect(result.from).toBe('github') + expect(result.verification.authorized_category).toBe('community') + }) + }) +}) + +// ================================ +// Steps Components Tests +// ================================ + +// SetURL Component Tests +describe('SetURL Component', () => { + // Import the real component for testing + const SetURL = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + // Re-mock the SetURL component with a more testable version + vi.doMock('./steps/setURL', () => ({ + default: SetURL, + })) + }) + + describe('Rendering', () => { + it('should render label with correct text', () => { + render() + + // The mocked component should be rendered + expect(screen.getByTestId('set-url-step')).toBeInTheDocument() + }) + + it('should render input field with placeholder', () => { + render() + + const input = screen.getByTestId('repo-url-input') + expect(input).toBeInTheDocument() + }) + + it('should render cancel and next buttons', () => { + render() + + expect(screen.getByTestId('cancel-btn')).toBeInTheDocument() + expect(screen.getByTestId('next-btn')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should display repoUrl value in input', () => { + render() + + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/test/repo' } }) + + expect(input).toHaveValue('https://github.com/test/repo') + }) + + it('should call onChange when input value changes', () => { + render() + + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'new-value' } }) + + expect(input).toHaveValue('new-value') + }) + }) + + describe('User Interactions', () => { + it('should call onNext when next button is clicked', async () => { + mockFetchReleases.mockResolvedValue(createMockReleases()) + + render() + + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } }) + + fireEvent.click(screen.getByTestId('next-btn')) + + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalled() + }) + }) + + it('should call onCancel when cancel button is clicked', () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByTestId('cancel-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty URL input', () => { + render() + + const input = screen.getByTestId('repo-url-input') + expect(input).toHaveValue('') + }) + + it('should handle URL with whitespace only', () => { + render() + + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: ' ' } }) + + // With whitespace only, next should still be submittable but validation will fail + fireEvent.click(screen.getByTestId('next-btn')) + + // Should show error for invalid URL + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'plugin.error.inValidGitHubUrl', + }) + }) + }) +}) + +// SelectPackage Component Tests +describe('SelectPackage Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchReleases.mockResolvedValue(createMockReleases()) + mockGetIconUrl.mockResolvedValue('processed-icon-url') + }) + + describe('Rendering', () => { + it('should render version selector', () => { + render( + , + ) + + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + }) + + it('should render package selector', () => { + render( + , + ) + + expect(screen.getByTestId('selected-package')).toBeInTheDocument() + }) + + it('should show back button when not in edit mode', async () => { + render() + + // Navigate to selectPackage step + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } }) + fireEvent.click(screen.getByTestId('next-btn')) + + await waitFor(() => { + expect(screen.getByTestId('back-btn')).toBeInTheDocument() + }) + }) + }) + + describe('Props', () => { + it('should display versions count correctly', () => { + render( + , + ) + + expect(screen.getByTestId('versions-count')).toHaveTextContent('2') + }) + + it('should display packages count based on selected version', async () => { + render( + , + ) + + // Initially 0 packages + expect(screen.getByTestId('packages-count')).toHaveTextContent('0') + + // Select version + fireEvent.click(screen.getByTestId('select-version-btn')) + + await waitFor(() => { + expect(screen.getByTestId('packages-count')).toHaveTextContent('2') + }) + }) + }) + + describe('User Interactions', () => { + it('should call onSelectVersion when version is selected', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('select-version-btn')) + + expect(screen.getByTestId('selected-version')).toHaveTextContent('v1.0.0') + }) + + it('should call onSelectPackage when package is selected', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('select-package-btn')) + + expect(screen.getByTestId('selected-package')).toHaveTextContent('package.zip') + }) + + it('should call onBack when back button is clicked', async () => { + render() + + // Navigate to selectPackage + const input = screen.getByTestId('repo-url-input') + fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } }) + fireEvent.click(screen.getByTestId('next-btn')) + + await waitFor(() => { + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('back-btn')) + + await waitFor(() => { + expect(screen.getByTestId('set-url-step')).toBeInTheDocument() + }) + }) + + it('should trigger upload when conditions are met', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + }) + }) + + describe('Upload Handling', () => { + it('should call onUploaded on successful upload', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(mockGetIconUrl).toHaveBeenCalled() + }) + }) + + it('should call onFailed on upload failure', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + }) + }) + + it('should handle upload error with response message', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('error-msg')).toHaveTextContent('Upload failed error') + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty versions array', () => { + const updatePayload = createUpdatePayload({ + originalPackageInfo: { + id: 'id', + repo: 'owner/repo', + version: 'v1.0.0', + package: 'pkg.zip', + releases: [], + }, + }) + + render( + , + ) + + expect(screen.getByTestId('versions-count')).toHaveTextContent('0') + }) + + it('should handle version with no assets', () => { + const updatePayload = createUpdatePayload({ + originalPackageInfo: { + id: 'id', + repo: 'owner/repo', + version: 'v1.0.0', + package: 'pkg.zip', + releases: [{ tag_name: 'v1.0.0', assets: [] }], + }, + }) + + render( + , + ) + + // Select the empty version + fireEvent.click(screen.getByTestId('select-version-btn')) + + expect(screen.getByTestId('packages-count')).toHaveTextContent('0') + }) + }) +}) + +// Loaded Component Tests +describe('Loaded Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetIconUrl.mockResolvedValue('processed-icon-url') + mockFetchReleases.mockResolvedValue(createMockReleases()) + mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), + } + }) + + describe('Rendering', () => { + it('should render ready to install message', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + }) + + it('should render plugin card with correct payload', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('payload-name')).toHaveTextContent('Test Plugin') + }) + }) + + it('should render back button when not installing', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-back-btn')).toBeInTheDocument() + }) + }) + + it('should render install button', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('install-success-btn')).toBeInTheDocument() + }) + }) + }) + + describe('Props', () => { + it('should display correct uniqueIdentifier', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('unique-identifier')).toHaveTextContent('test-unique-id') + }) + }) + + it('should display correct repoUrl', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-repo-url')).toHaveTextContent('https://github.com/owner/repo') + }) + }) + + it('should display selected version and package', async () => { + render( + , + ) + + // First select version and package + fireEvent.click(screen.getByTestId('select-version-btn')) + fireEvent.click(screen.getByTestId('select-package-btn')) + + // Then trigger upload + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-version')).toHaveTextContent('v1.0.0') + expect(screen.getByTestId('loaded-package')).toHaveTextContent('package.zip') + }) + }) + }) + + describe('User Interactions', () => { + it('should call onBack when back button is clicked', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('loaded-back-btn')) + + await waitFor(() => { + expect(screen.getByTestId('select-package-step')).toBeInTheDocument() + }) + }) + + it('should call onStartToInstall when install is triggered', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1) + }) + + it('should call onInstalled on successful installation', async () => { + const onSuccess = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled() + }) + }) + + it('should call onFailed on installation failure', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + }) + }) + }) + + describe('Installation Flows', () => { + it('should handle fresh install flow', async () => { + const onSuccess = vi.fn() + render( + , + ) + + // Navigate to loaded step + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + // Trigger install + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(onSuccess).toHaveBeenCalled() + }) + }) + + it('should handle update flow with updatePayload', async () => { + const onSuccess = vi.fn() + const updatePayload = createUpdatePayload() + + render( + , + ) + + // Navigate to loaded step + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + // Trigger install (update) + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled() + }) + }) + + it('should refresh plugin list after successful install', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(mockRefreshPluginList).toHaveBeenCalled() + }) + }) + + it('should not refresh plugin list when notRefresh is true', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-success-no-refresh-btn')) + + await waitFor(() => { + expect(mockRefreshPluginList).not.toHaveBeenCalled() + }) + }) + }) + + describe('Error Handling', () => { + it('should display error message on failure', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('error-msg')).toHaveTextContent('Install failed') + }) + }) + + it('should handle failure without error message', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('install-fail-no-msg-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle missing optional props', async () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + // Should not throw when onStartToInstall is called + expect(() => { + fireEvent.click(screen.getByTestId('start-install-btn')) + }).not.toThrow() + }) + + it('should preserve state through component updates', async () => { + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + + // Rerender + rerender( + , + ) + + // State should be preserved + expect(screen.getByTestId('loaded-step')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.spec.tsx new file mode 100644 index 0000000000..a8411fcc06 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.spec.tsx @@ -0,0 +1,525 @@ +import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, TaskStatus } from '../../../types' +import Loaded from './loaded' + +// Mock dependencies +const mockUseCheckInstalled = vi.fn() +vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ + default: (params: { pluginIds: string[], enabled: boolean }) => mockUseCheckInstalled(params), +})) + +const mockUpdateFromGitHub = vi.fn() +vi.mock('@/service/plugins', () => ({ + updateFromGitHub: (...args: unknown[]) => mockUpdateFromGitHub(...args), +})) + +const mockInstallPackageFromGitHub = vi.fn() +const mockHandleRefetch = vi.fn() +vi.mock('@/service/use-plugins', () => ({ + useInstallPackageFromGitHub: () => ({ mutateAsync: mockInstallPackageFromGitHub }), + usePluginTaskList: () => ({ handleRefetch: mockHandleRefetch }), +})) + +const mockCheck = vi.fn() +vi.mock('../../base/check-task-status', () => ({ + default: () => ({ check: mockCheck }), +})) + +// Mock Card component +vi.mock('../../../card', () => ({ + default: ({ payload, titleLeft }: { payload: Plugin, titleLeft?: React.ReactNode }) => ( +
+ {payload.name} + {titleLeft && {titleLeft}} +
+ ), +})) + +// Mock Version component +vi.mock('../../base/version', () => ({ + default: ({ hasInstalled, installedVersion, toInstallVersion }: { + hasInstalled: boolean + installedVersion?: string + toInstallVersion: string + }) => ( + + {hasInstalled ? `Update from ${installedVersion} to ${toInstallVersion}` : `Install ${toInstallVersion}`} + + ), +})) + +// Factory functions +const createMockPayload = (overrides: Partial = {}): PluginDeclaration => ({ + plugin_unique_identifier: 'test-uid', + version: '1.0.0', + author: 'test-author', + icon: 'icon.png', + name: 'Test Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Test' } as PluginDeclaration['label'], + description: { 'en-US': 'Test Description' } as PluginDeclaration['description'], + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], + ...overrides, +}) + +const createMockPluginPayload = (overrides: Partial = {}): Plugin => ({ + type: 'plugin', + org: 'test-org', + name: 'Test Plugin', + plugin_id: 'test-plugin-id', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'test-pkg', + icon: 'icon.png', + verified: true, + label: { 'en-US': 'Test' }, + brief: { 'en-US': 'Brief' }, + description: { 'en-US': 'Description' }, + introduction: 'Intro', + repository: '', + category: PluginCategoryEnum.tool, + install_count: 100, + endpoint: { settings: [] }, + tags: [], + badges: [], + verification: { authorized_category: 'langgenius' }, + from: 'github', + ...overrides, +}) + +const createUpdatePayload = (): UpdateFromGitHubPayload => ({ + originalPackageInfo: { + id: 'original-id', + repo: 'owner/repo', + version: 'v0.9.0', + package: 'plugin.zip', + releases: [], + }, +}) + +describe('Loaded', () => { + const defaultProps = { + updatePayload: undefined, + uniqueIdentifier: 'test-unique-id', + payload: createMockPayload() as PluginDeclaration | Plugin, + repoUrl: 'https://github.com/owner/repo', + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onBack: vi.fn(), + onStartToInstall: vi.fn(), + onInstalled: vi.fn(), + onFailed: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockUseCheckInstalled.mockReturnValue({ + installedInfo: {}, + isLoading: false, + }) + mockUpdateFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' }) + mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' }) + mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null }) + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render ready to install message', () => { + render() + + expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument() + }) + + it('should render plugin card', () => { + render() + + expect(screen.getByTestId('plugin-card')).toBeInTheDocument() + }) + + it('should render back button when not installing', () => { + render() + + expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument() + }) + + it('should render install button', () => { + render() + + expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).toBeInTheDocument() + }) + + it('should show version info in card title', () => { + render() + + expect(screen.getByTestId('version-info')).toBeInTheDocument() + }) + }) + + // ================================ + // Props Tests + // ================================ + describe('Props', () => { + it('should display plugin name from payload', () => { + render() + + expect(screen.getByTestId('card-name')).toHaveTextContent('Test Plugin') + }) + + it('should pass correct version to Version component', () => { + render() + + expect(screen.getByTestId('version-info')).toHaveTextContent('Install 2.0.0') + }) + }) + + // ================================ + // Button State Tests + // ================================ + describe('Button State', () => { + it('should disable install button while loading', () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: {}, + isLoading: true, + }) + + render() + + expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).toBeDisabled() + }) + + it('should enable install button when not loading', () => { + render() + + expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).not.toBeDisabled() + }) + }) + + // ================================ + // User Interactions Tests + // ================================ + describe('User Interactions', () => { + it('should call onBack when back button is clicked', () => { + const onBack = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' })) + + expect(onBack).toHaveBeenCalledTimes(1) + }) + + it('should call onStartToInstall when install starts', async () => { + const onStartToInstall = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(onStartToInstall).toHaveBeenCalledTimes(1) + }) + }) + }) + + // ================================ + // Installation Flow Tests + // ================================ + describe('Installation Flows', () => { + it('should call installPackageFromGitHub for fresh install', async () => { + const onInstalled = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(mockInstallPackageFromGitHub).toHaveBeenCalledWith({ + repoUrl: 'owner/repo', + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + uniqueIdentifier: 'test-unique-id', + }) + }) + }) + + it('should call updateFromGitHub when updatePayload is provided', async () => { + const updatePayload = createUpdatePayload() + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(mockUpdateFromGitHub).toHaveBeenCalledWith( + 'owner/repo', + 'v1.0.0', + 'plugin.zip', + 'original-id', + 'test-unique-id', + ) + }) + }) + + it('should call updateFromGitHub when plugin is already installed', async () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: { + 'test-plugin-id': { + installedVersion: '0.9.0', + uniqueIdentifier: 'installed-uid', + }, + }, + isLoading: false, + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(mockUpdateFromGitHub).toHaveBeenCalledWith( + 'owner/repo', + 'v1.0.0', + 'plugin.zip', + 'installed-uid', + 'test-unique-id', + ) + }) + }) + + it('should call onInstalled when installation completes immediately', async () => { + mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' }) + + const onInstalled = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(onInstalled).toHaveBeenCalled() + }) + }) + + it('should check task status when not immediately installed', async () => { + mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' }) + + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(mockHandleRefetch).toHaveBeenCalled() + expect(mockCheck).toHaveBeenCalledWith({ + taskId: 'task-1', + pluginUniqueIdentifier: 'test-unique-id', + }) + }) + }) + + it('should call onInstalled with true when task succeeds', async () => { + mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' }) + mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null }) + + const onInstalled = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(onInstalled).toHaveBeenCalledWith(true) + }) + }) + }) + + // ================================ + // Error Handling Tests + // ================================ + describe('Error Handling', () => { + it('should call onFailed when task fails', async () => { + mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' }) + mockCheck.mockResolvedValue({ status: TaskStatus.failed, error: 'Installation failed' }) + + const onFailed = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith('Installation failed') + }) + }) + + it('should call onFailed with string error', async () => { + mockInstallPackageFromGitHub.mockRejectedValue('String error message') + + const onFailed = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith('String error message') + }) + }) + + it('should call onFailed without message for non-string errors', async () => { + mockInstallPackageFromGitHub.mockRejectedValue(new Error('Error object')) + + const onFailed = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith() + }) + }) + }) + + // ================================ + // Auto-install Effect Tests + // ================================ + describe('Auto-install Effect', () => { + it('should call onInstalled when already installed with same identifier', () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: { + 'test-plugin-id': { + installedVersion: '1.0.0', + uniqueIdentifier: 'test-unique-id', + }, + }, + isLoading: false, + }) + + const onInstalled = vi.fn() + render() + + expect(onInstalled).toHaveBeenCalled() + }) + + it('should not call onInstalled when identifiers differ', () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: { + 'test-plugin-id': { + installedVersion: '1.0.0', + uniqueIdentifier: 'different-uid', + }, + }, + isLoading: false, + }) + + const onInstalled = vi.fn() + render() + + expect(onInstalled).not.toHaveBeenCalled() + }) + }) + + // ================================ + // Installing State Tests + // ================================ + describe('Installing State', () => { + it('should hide back button while installing', async () => { + let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void + mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => { + resolveInstall = resolve + })) + + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument() + }) + + resolveInstall!({ all_installed: true, task_id: 'task-1' }) + }) + + it('should show installing text while installing', async () => { + let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void + mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => { + resolveInstall = resolve + })) + + render() + + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument() + }) + + resolveInstall!({ all_installed: true, task_id: 'task-1' }) + }) + + it('should not trigger install twice when already installing', async () => { + let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void + mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => { + resolveInstall = resolve + })) + + render() + + const installButton = screen.getByRole('button', { name: /plugin.installModal.install/i }) + + // Click twice + fireEvent.click(installButton) + fireEvent.click(installButton) + + await waitFor(() => { + expect(mockInstallPackageFromGitHub).toHaveBeenCalledTimes(1) + }) + + resolveInstall!({ all_installed: true, task_id: 'task-1' }) + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle missing onStartToInstall callback', async () => { + render() + + // Should not throw when callback is undefined + expect(() => { + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i })) + }).not.toThrow() + + await waitFor(() => { + expect(mockInstallPackageFromGitHub).toHaveBeenCalled() + }) + }) + + it('should handle plugin without plugin_id', () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: {}, + isLoading: false, + }) + + render() + + expect(mockUseCheckInstalled).toHaveBeenCalledWith({ + pluginIds: [undefined], + enabled: false, + }) + }) + + it('should preserve state after component update', () => { + const { rerender } = render() + + rerender() + + expect(screen.getByTestId('plugin-card')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx index 3bff22816b..d90edf8766 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx @@ -16,7 +16,7 @@ import Version from '../../base/version' import { parseGitHubUrl, pluginManifestToCardPluginProps } from '../../utils' type LoadedProps = { - updatePayload: UpdateFromGitHubPayload + updatePayload?: UpdateFromGitHubPayload uniqueIdentifier: string payload: PluginDeclaration | Plugin repoUrl: string diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.spec.tsx new file mode 100644 index 0000000000..71f0e5e497 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.spec.tsx @@ -0,0 +1,877 @@ +import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types' +import type { Item } from '@/app/components/base/select' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '../../../types' +import SelectPackage from './selectPackage' + +// Mock the useGitHubUpload hook +const mockHandleUpload = vi.fn() +vi.mock('../../hooks', () => ({ + useGitHubUpload: () => ({ handleUpload: mockHandleUpload }), +})) + +// Factory functions +const createMockManifest = (): PluginDeclaration => ({ + plugin_unique_identifier: 'test-uid', + version: '1.0.0', + author: 'test-author', + icon: 'icon.png', + name: 'Test Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Test' } as PluginDeclaration['label'], + description: { 'en-US': 'Test Description' } as PluginDeclaration['description'], + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], +}) + +const createVersions = (): Item[] => [ + { value: 'v1.0.0', name: 'v1.0.0' }, + { value: 'v0.9.0', name: 'v0.9.0' }, +] + +const createPackages = (): Item[] => [ + { value: 'plugin.zip', name: 'plugin.zip' }, + { value: 'plugin.tar.gz', name: 'plugin.tar.gz' }, +] + +const createUpdatePayload = (): UpdateFromGitHubPayload => ({ + originalPackageInfo: { + id: 'original-id', + repo: 'owner/repo', + version: 'v0.9.0', + package: 'plugin.zip', + releases: [], + }, +}) + +// Test props type - updatePayload is optional for testing +type TestProps = { + updatePayload?: UpdateFromGitHubPayload + repoUrl?: string + selectedVersion?: string + versions?: Item[] + onSelectVersion?: (item: Item) => void + selectedPackage?: string + packages?: Item[] + onSelectPackage?: (item: Item) => void + onUploaded?: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void + onFailed?: (errorMsg: string) => void + onBack?: () => void +} + +describe('SelectPackage', () => { + const createDefaultProps = () => ({ + updatePayload: undefined as UpdateFromGitHubPayload | undefined, + repoUrl: 'https://github.com/owner/repo', + selectedVersion: '', + versions: createVersions(), + onSelectVersion: vi.fn() as (item: Item) => void, + selectedPackage: '', + packages: createPackages(), + onSelectPackage: vi.fn() as (item: Item) => void, + onUploaded: vi.fn() as (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void, + onFailed: vi.fn() as (errorMsg: string) => void, + onBack: vi.fn() as () => void, + }) + + // Helper function to render with proper type handling + const renderSelectPackage = (overrides: TestProps = {}) => { + const props = { ...createDefaultProps(), ...overrides } + // Cast to any to bypass strict type checking since component accepts optional updatePayload + return render([0])} />) + } + + beforeEach(() => { + vi.clearAllMocks() + mockHandleUpload.mockReset() + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render version label', () => { + renderSelectPackage() + + expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument() + }) + + it('should render package label', () => { + renderSelectPackage() + + expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument() + }) + + it('should render back button when not in edit mode', () => { + renderSelectPackage({ updatePayload: undefined }) + + expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument() + }) + + it('should not render back button when in edit mode', () => { + renderSelectPackage({ updatePayload: createUpdatePayload() }) + + expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument() + }) + + it('should render next button', () => { + renderSelectPackage() + + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeInTheDocument() + }) + }) + + // ================================ + // Props Tests + // ================================ + describe('Props', () => { + it('should pass selectedVersion to PortalSelect', () => { + renderSelectPackage({ selectedVersion: 'v1.0.0' }) + + // PortalSelect should display the selected version + expect(screen.getByText('v1.0.0')).toBeInTheDocument() + }) + + it('should pass selectedPackage to PortalSelect', () => { + renderSelectPackage({ selectedPackage: 'plugin.zip' }) + + expect(screen.getByText('plugin.zip')).toBeInTheDocument() + }) + + it('should show installed version badge when updatePayload version differs', () => { + renderSelectPackage({ + updatePayload: createUpdatePayload(), + selectedVersion: 'v1.0.0', + }) + + expect(screen.getByText(/v0\.9\.0\s*->\s*v1\.0\.0/)).toBeInTheDocument() + }) + }) + + // ================================ + // Button State Tests + // ================================ + describe('Button State', () => { + it('should disable next button when no version selected', () => { + renderSelectPackage({ selectedVersion: '', selectedPackage: '' }) + + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled() + }) + + it('should disable next button when version selected but no package', () => { + renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: '' }) + + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled() + }) + + it('should enable next button when both version and package selected', () => { + renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: 'plugin.zip' }) + + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled() + }) + }) + + // ================================ + // User Interactions Tests + // ================================ + describe('User Interactions', () => { + it('should call onBack when back button is clicked', () => { + const onBack = vi.fn() + renderSelectPackage({ onBack }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' })) + + expect(onBack).toHaveBeenCalledTimes(1) + }) + + it('should call handleUploadPackage when next button is clicked', async () => { + mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => { + onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() }) + }) + + const onUploaded = vi.fn() + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onUploaded, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(mockHandleUpload).toHaveBeenCalledTimes(1) + expect(mockHandleUpload).toHaveBeenCalledWith( + 'owner/repo', + 'v1.0.0', + 'plugin.zip', + expect.any(Function), + ) + }) + }) + + it('should not invoke upload when next button is disabled', () => { + renderSelectPackage({ selectedVersion: '', selectedPackage: '' }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + expect(mockHandleUpload).not.toHaveBeenCalled() + }) + }) + + // ================================ + // Upload Handling Tests + // ================================ + describe('Upload Handling', () => { + it('should call onUploaded with correct data on successful upload', async () => { + const mockManifest = createMockManifest() + mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => { + onSuccess({ unique_identifier: 'test-uid', manifest: mockManifest }) + }) + + const onUploaded = vi.fn() + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onUploaded, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(onUploaded).toHaveBeenCalledWith({ + uniqueIdentifier: 'test-uid', + manifest: mockManifest, + }) + }) + }) + + it('should call onFailed with response message on upload error', async () => { + mockHandleUpload.mockRejectedValue({ response: { message: 'API Error' } }) + + const onFailed = vi.fn() + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onFailed, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith('API Error') + }) + }) + + it('should call onFailed with default message when no response message', async () => { + mockHandleUpload.mockRejectedValue(new Error('Network error')) + + const onFailed = vi.fn() + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onFailed, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed') + }) + }) + + it('should not call upload twice when already uploading', async () => { + let resolveUpload: (value?: unknown) => void + mockHandleUpload.mockImplementation(() => new Promise((resolve) => { + resolveUpload = resolve + })) + + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + }) + + const nextButton = screen.getByRole('button', { name: 'plugin.installModal.next' }) + + // Click twice rapidly - this tests the isUploading guard at line 49-50 + // The first click starts the upload, the second should be ignored + fireEvent.click(nextButton) + fireEvent.click(nextButton) + + await waitFor(() => { + expect(mockHandleUpload).toHaveBeenCalledTimes(1) + }) + + // Resolve the upload + resolveUpload!() + }) + + it('should disable back button while uploading', async () => { + let resolveUpload: (value?: unknown) => void + mockHandleUpload.mockImplementation(() => new Promise((resolve) => { + resolveUpload = resolve + })) + + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled() + }) + + resolveUpload!() + }) + + it('should strip github.com prefix from repoUrl', async () => { + mockHandleUpload.mockResolvedValue({}) + + renderSelectPackage({ + repoUrl: 'https://github.com/myorg/myrepo', + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(mockHandleUpload).toHaveBeenCalledWith( + 'myorg/myrepo', + expect.any(String), + expect.any(String), + expect.any(Function), + ) + }) + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty versions array', () => { + renderSelectPackage({ versions: [] }) + + expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument() + }) + + it('should handle empty packages array', () => { + renderSelectPackage({ packages: [] }) + + expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument() + }) + + it('should handle updatePayload with installed version', () => { + renderSelectPackage({ updatePayload: createUpdatePayload() }) + + // Should not show back button in edit mode + expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument() + }) + + it('should re-enable buttons after upload completes', async () => { + mockHandleUpload.mockResolvedValue({}) + + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled() + }) + }) + + it('should re-enable buttons after upload fails', async () => { + mockHandleUpload.mockRejectedValue(new Error('Upload failed')) + + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled() + }) + }) + }) + + // ================================ + // PortalSelect Readonly State Tests + // ================================ + describe('PortalSelect Readonly State', () => { + it('should make package select readonly when no version selected', () => { + renderSelectPackage({ selectedVersion: '' }) + + // When no version is selected, package select should be readonly + // This is tested by verifying the component renders correctly + const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div') + expect(trigger).toHaveClass('cursor-not-allowed') + }) + + it('should make package select active when version is selected', () => { + renderSelectPackage({ selectedVersion: 'v1.0.0' }) + + // When version is selected, package select should be active + const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div') + expect(trigger).toHaveClass('cursor-pointer') + }) + }) + + // ================================ + // installedValue Props Tests + // ================================ + describe('installedValue Props', () => { + it('should pass installedValue when updatePayload is provided', () => { + const updatePayload = createUpdatePayload() + renderSelectPackage({ updatePayload }) + + // The installed version should be passed to PortalSelect + // updatePayload.originalPackageInfo.version = 'v0.9.0' + expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument() + }) + + it('should not pass installedValue when updatePayload is undefined', () => { + renderSelectPackage({ updatePayload: undefined }) + + // No installed version indicator + expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument() + }) + + it('should handle updatePayload with different version value', () => { + const updatePayload = createUpdatePayload() + updatePayload.originalPackageInfo.version = 'v2.0.0' + renderSelectPackage({ updatePayload }) + + // Should render without errors + expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument() + }) + + it('should show installed badge in version list', () => { + const updatePayload = createUpdatePayload() + renderSelectPackage({ updatePayload, selectedVersion: '' }) + + fireEvent.click(screen.getByText('plugin.installFromGitHub.selectVersionPlaceholder')) + + expect(screen.getByText('INSTALLED')).toBeInTheDocument() + }) + }) + + // ================================ + // Next Button Disabled State Combinations + // ================================ + describe('Next Button Disabled State Combinations', () => { + it('should disable next button when only version is missing', () => { + renderSelectPackage({ selectedVersion: '', selectedPackage: 'plugin.zip' }) + + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled() + }) + + it('should disable next button when only package is missing', () => { + renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: '' }) + + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled() + }) + + it('should disable next button when both are missing', () => { + renderSelectPackage({ selectedVersion: '', selectedPackage: '' }) + + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled() + }) + + it('should disable next button when uploading even with valid selections', async () => { + let resolveUpload: (value?: unknown) => void + mockHandleUpload.mockImplementation(() => new Promise((resolve) => { + resolveUpload = resolve + })) + + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled() + }) + + resolveUpload!() + }) + }) + + // ================================ + // RepoUrl Format Handling Tests + // ================================ + describe('RepoUrl Format Handling', () => { + it('should handle repoUrl without trailing slash', async () => { + mockHandleUpload.mockResolvedValue({}) + + renderSelectPackage({ + repoUrl: 'https://github.com/owner/repo', + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(mockHandleUpload).toHaveBeenCalledWith( + 'owner/repo', + 'v1.0.0', + 'plugin.zip', + expect.any(Function), + ) + }) + }) + + it('should handle repoUrl with different org/repo combinations', async () => { + mockHandleUpload.mockResolvedValue({}) + + renderSelectPackage({ + repoUrl: 'https://github.com/my-organization/my-plugin-repo', + selectedVersion: 'v2.0.0', + selectedPackage: 'build.tar.gz', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(mockHandleUpload).toHaveBeenCalledWith( + 'my-organization/my-plugin-repo', + 'v2.0.0', + 'build.tar.gz', + expect.any(Function), + ) + }) + }) + + it('should pass through repoUrl without github prefix', async () => { + mockHandleUpload.mockResolvedValue({}) + + renderSelectPackage({ + repoUrl: 'plain-org/plain-repo', + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(mockHandleUpload).toHaveBeenCalledWith( + 'plain-org/plain-repo', + 'v1.0.0', + 'plugin.zip', + expect.any(Function), + ) + }) + }) + }) + + // ================================ + // isEdit Mode Comprehensive Tests + // ================================ + describe('isEdit Mode Comprehensive', () => { + it('should set isEdit to true when updatePayload is truthy', () => { + const updatePayload = createUpdatePayload() + renderSelectPackage({ updatePayload }) + + // Back button should not be rendered in edit mode + expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument() + }) + + it('should set isEdit to false when updatePayload is undefined', () => { + renderSelectPackage({ updatePayload: undefined }) + + // Back button should be rendered when not in edit mode + expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument() + }) + + it('should allow upload in edit mode without back button', async () => { + mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => { + onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() }) + }) + + const onUploaded = vi.fn() + renderSelectPackage({ + updatePayload: createUpdatePayload(), + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onUploaded, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(onUploaded).toHaveBeenCalled() + }) + }) + }) + + // ================================ + // Error Response Handling Tests + // ================================ + describe('Error Response Handling', () => { + it('should handle error with response.message property', async () => { + mockHandleUpload.mockRejectedValue({ response: { message: 'Custom API Error' } }) + + const onFailed = vi.fn() + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onFailed, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith('Custom API Error') + }) + }) + + it('should handle error with empty response object', async () => { + mockHandleUpload.mockRejectedValue({ response: {} }) + + const onFailed = vi.fn() + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onFailed, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed') + }) + }) + + it('should handle error without response property', async () => { + mockHandleUpload.mockRejectedValue({ code: 'NETWORK_ERROR' }) + + const onFailed = vi.fn() + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onFailed, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed') + }) + }) + + it('should handle error with response but no message', async () => { + mockHandleUpload.mockRejectedValue({ response: { status: 500 } }) + + const onFailed = vi.fn() + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onFailed, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed') + }) + }) + + it('should handle string error', async () => { + mockHandleUpload.mockRejectedValue('String error message') + + const onFailed = vi.fn() + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onFailed, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed') + }) + }) + }) + + // ================================ + // Callback Props Tests + // ================================ + describe('Callback Props', () => { + it('should pass onSelectVersion to PortalSelect', () => { + const onSelectVersion = vi.fn() + renderSelectPackage({ onSelectVersion }) + + // The callback is passed to PortalSelect, which is a base component + // We verify it's rendered correctly + expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument() + }) + + it('should pass onSelectPackage to PortalSelect', () => { + const onSelectPackage = vi.fn() + renderSelectPackage({ onSelectPackage }) + + // The callback is passed to PortalSelect, which is a base component + expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument() + }) + }) + + // ================================ + // Upload State Management Tests + // ================================ + describe('Upload State Management', () => { + it('should set isUploading to true when upload starts', async () => { + let resolveUpload: (value?: unknown) => void + mockHandleUpload.mockImplementation(() => new Promise((resolve) => { + resolveUpload = resolve + })) + + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + // Both buttons should be disabled during upload + await waitFor(() => { + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled() + }) + + resolveUpload!() + }) + + it('should set isUploading to false after successful upload', async () => { + mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => { + onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() }) + }) + + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled() + expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled() + }) + }) + + it('should set isUploading to false after failed upload', async () => { + mockHandleUpload.mockRejectedValue(new Error('Upload failed')) + + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled() + expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled() + }) + }) + + it('should not allow back button click while uploading', async () => { + let resolveUpload: (value?: unknown) => void + mockHandleUpload.mockImplementation(() => new Promise((resolve) => { + resolveUpload = resolve + })) + + const onBack = vi.fn() + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onBack, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled() + }) + + // Try to click back button while disabled + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' })) + + // onBack should not be called + expect(onBack).not.toHaveBeenCalled() + + resolveUpload!() + }) + }) + + // ================================ + // handleUpload Callback Tests + // ================================ + describe('handleUpload Callback', () => { + it('should invoke onSuccess callback with correct data structure', async () => { + const mockManifest = createMockManifest() + mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => { + onSuccess({ + unique_identifier: 'test-unique-identifier', + manifest: mockManifest, + }) + }) + + const onUploaded = vi.fn() + renderSelectPackage({ + selectedVersion: 'v1.0.0', + selectedPackage: 'plugin.zip', + onUploaded, + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(onUploaded).toHaveBeenCalledWith({ + uniqueIdentifier: 'test-unique-identifier', + manifest: mockManifest, + }) + }) + }) + + it('should pass correct repo, version, and package to handleUpload', async () => { + mockHandleUpload.mockResolvedValue({}) + + renderSelectPackage({ + repoUrl: 'https://github.com/test-org/test-repo', + selectedVersion: 'v3.0.0', + selectedPackage: 'release.zip', + }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + await waitFor(() => { + expect(mockHandleUpload).toHaveBeenCalledWith( + 'test-org/test-repo', + 'v3.0.0', + 'release.zip', + expect.any(Function), + ) + }) + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.spec.tsx new file mode 100644 index 0000000000..11fa3057e3 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-from-github/steps/setURL.spec.tsx @@ -0,0 +1,180 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SetURL from './setURL' + +describe('SetURL', () => { + const defaultProps = { + repoUrl: '', + onChange: vi.fn(), + onNext: vi.fn(), + onCancel: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render label with GitHub repo text', () => { + render() + + expect(screen.getByText('plugin.installFromGitHub.gitHubRepo')).toBeInTheDocument() + }) + + it('should render input field with correct attributes', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('type', 'url') + expect(input).toHaveAttribute('id', 'repoUrl') + expect(input).toHaveAttribute('name', 'repoUrl') + expect(input).toHaveAttribute('placeholder', 'Please enter GitHub repo URL') + }) + + it('should render cancel button', () => { + render() + + expect(screen.getByRole('button', { name: 'plugin.installModal.cancel' })).toBeInTheDocument() + }) + + it('should render next button', () => { + render() + + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeInTheDocument() + }) + + it('should associate label with input field', () => { + render() + + const input = screen.getByLabelText('plugin.installFromGitHub.gitHubRepo') + expect(input).toBeInTheDocument() + }) + }) + + // ================================ + // Props Tests + // ================================ + describe('Props', () => { + it('should display repoUrl value in input', () => { + render() + + expect(screen.getByRole('textbox')).toHaveValue('https://github.com/test/repo') + }) + + it('should display empty string when repoUrl is empty', () => { + render() + + expect(screen.getByRole('textbox')).toHaveValue('') + }) + }) + + // ================================ + // User Interactions Tests + // ================================ + describe('User Interactions', () => { + it('should call onChange when input value changes', () => { + const onChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith('https://github.com/owner/repo') + }) + + it('should call onCancel when cancel button is clicked', () => { + const onCancel = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.cancel' })) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should call onNext when next button is clicked', () => { + const onNext = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + expect(onNext).toHaveBeenCalledTimes(1) + }) + }) + + // ================================ + // Button State Tests + // ================================ + describe('Button State', () => { + it('should disable next button when repoUrl is empty', () => { + render() + + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled() + }) + + it('should disable next button when repoUrl is only whitespace', () => { + render() + + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled() + }) + + it('should enable next button when repoUrl has content', () => { + render() + + expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled() + }) + + it('should not disable cancel button regardless of repoUrl', () => { + render() + + expect(screen.getByRole('button', { name: 'plugin.installModal.cancel' })).not.toBeDisabled() + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle URL with special characters', () => { + const onChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'https://github.com/test-org/repo_name-123' } }) + + expect(onChange).toHaveBeenCalledWith('https://github.com/test-org/repo_name-123') + }) + + it('should handle very long URLs', () => { + const longUrl = `https://github.com/${'a'.repeat(100)}/${'b'.repeat(100)}` + render() + + expect(screen.getByRole('textbox')).toHaveValue(longUrl) + }) + + it('should handle onChange with empty string', () => { + const onChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '' } }) + + expect(onChange).toHaveBeenCalledWith('') + }) + + it('should preserve callback references on rerender', () => { + const onNext = vi.fn() + const { rerender } = render() + + rerender() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' })) + + expect(onNext).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/index.spec.tsx new file mode 100644 index 0000000000..18225dd48d --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-from-local-package/index.spec.tsx @@ -0,0 +1,2097 @@ +import type { Dependency, PluginDeclaration } from '../../types' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { InstallStep, PluginCategoryEnum } from '../../types' +import InstallFromLocalPackage from './index' + +// Factory functions for test data +const createMockManifest = (overrides: Partial = {}): PluginDeclaration => ({ + plugin_unique_identifier: 'test-plugin-uid', + version: '1.0.0', + author: 'test-author', + icon: 'test-icon.png', + name: 'Test Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'], + description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'], + created_at: '2024-01-01T00:00:00Z', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], + ...overrides, +}) + +const createMockDependencies = (): Dependency[] => [ + { + type: 'package', + value: { + unique_identifier: 'dep-1', + manifest: createMockManifest({ name: 'Dep Plugin 1' }), + }, + }, + { + type: 'package', + value: { + unique_identifier: 'dep-2', + manifest: createMockManifest({ name: 'Dep Plugin 2' }), + }, + }, +] + +const createMockFile = (name: string = 'test-plugin.difypkg'): File => { + return new File(['test content'], name, { type: 'application/octet-stream' }) +} + +const createMockBundleFile = (): File => { + return new File(['bundle content'], 'test-bundle.difybndl', { type: 'application/octet-stream' }) +} + +// Mock external dependencies +const mockGetIconUrl = vi.fn() +vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({ + default: () => ({ getIconUrl: mockGetIconUrl }), +})) + +let mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), +} +vi.mock('../hooks/use-hide-logic', () => ({ + default: () => mockHideLogicState, +})) + +// Mock child components +let uploadingOnPackageUploaded: ((result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void) | null = null +let uploadingOnBundleUploaded: ((result: Dependency[]) => void) | null = null +let _uploadingOnFailed: ((errorMsg: string) => void) | null = null + +vi.mock('./steps/uploading', () => ({ + default: ({ + isBundle, + file, + onCancel, + onPackageUploaded, + onBundleUploaded, + onFailed, + }: { + isBundle: boolean + file: File + onCancel: () => void + onPackageUploaded: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void + onBundleUploaded: (result: Dependency[]) => void + onFailed: (errorMsg: string) => void + }) => { + uploadingOnPackageUploaded = onPackageUploaded + uploadingOnBundleUploaded = onBundleUploaded + _uploadingOnFailed = onFailed + return ( +
+ {isBundle ? 'true' : 'false'} + {file.name} + + + + +
+ ) + }, +})) + +let _packageStepChangeCallback: ((step: InstallStep) => void) | null = null +let _packageSetIsInstallingCallback: ((isInstalling: boolean) => void) | null = null +let _packageOnErrorCallback: ((errorMsg: string) => void) | null = null + +vi.mock('./ready-to-install', () => ({ + default: ({ + step, + onStepChange, + onStartToInstall, + setIsInstalling, + onClose, + uniqueIdentifier, + manifest, + errorMsg, + onError, + }: { + step: InstallStep + onStepChange: (step: InstallStep) => void + onStartToInstall: () => void + setIsInstalling: (isInstalling: boolean) => void + onClose: () => void + uniqueIdentifier: string | null + manifest: PluginDeclaration | null + errorMsg: string | null + onError: (errorMsg: string) => void + }) => { + _packageStepChangeCallback = onStepChange + _packageSetIsInstallingCallback = setIsInstalling + _packageOnErrorCallback = onError + return ( +
+ {step} + {uniqueIdentifier || 'null'} + {manifest?.name || 'null'} + {errorMsg || 'null'} + + + + + + +
+ ) + }, +})) + +let _bundleStepChangeCallback: ((step: InstallStep) => void) | null = null +let _bundleSetIsInstallingCallback: ((isInstalling: boolean) => void) | null = null + +vi.mock('../install-bundle/ready-to-install', () => ({ + default: ({ + step, + onStepChange, + onStartToInstall, + setIsInstalling, + onClose, + allPlugins, + }: { + step: InstallStep + onStepChange: (step: InstallStep) => void + onStartToInstall: () => void + setIsInstalling: (isInstalling: boolean) => void + onClose: () => void + allPlugins: Dependency[] + }) => { + _bundleStepChangeCallback = onStepChange + _bundleSetIsInstallingCallback = setIsInstalling + return ( +
+ {step} + {allPlugins.length} + + + + + +
+ ) + }, +})) + +describe('InstallFromLocalPackage', () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetIconUrl.mockReturnValue('processed-icon-url') + mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), + } + uploadingOnPackageUploaded = null + uploadingOnBundleUploaded = null + _uploadingOnFailed = null + _packageStepChangeCallback = null + _packageSetIsInstallingCallback = null + _packageOnErrorCallback = null + _bundleStepChangeCallback = null + _bundleSetIsInstallingCallback = null + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render modal with uploading step initially', () => { + render() + + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + expect(screen.getByTestId('file-name')).toHaveTextContent('test-plugin.difypkg') + }) + + it('should render with correct modal title for uploading step', () => { + render() + + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should apply modal className from useHideLogic', () => { + expect(mockHideLogicState.modalClassName).toBe('test-modal-class') + }) + + it('should identify bundle file correctly', () => { + render() + + expect(screen.getByTestId('is-bundle')).toHaveTextContent('true') + }) + + it('should identify package file correctly', () => { + render() + + expect(screen.getByTestId('is-bundle')).toHaveTextContent('false') + }) + }) + + // ================================ + // Title Display Tests + // ================================ + describe('Title Display', () => { + it('should show install plugin title initially', () => { + render() + + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should show upload failed title when upload fails', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.uploadFailed')).toBeInTheDocument() + }) + }) + + it('should show installed successfully title for package when installed', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-step-installed-btn')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installedSuccessfully')).toBeInTheDocument() + }) + }) + + it('should show install complete title for bundle when installed', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-step-installed-btn')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + }) + }) + + it('should show install failed title when install fails', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-step-failed-btn')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument() + }) + }) + }) + + // ================================ + // State Management Tests + // ================================ + describe('State Management', () => { + it('should transition from uploading to readyToInstall on successful package upload', async () => { + render() + + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + expect(screen.getByTestId('package-step')).toHaveTextContent('readyToInstall') + }) + }) + + it('should transition from uploading to readyToInstall on successful bundle upload', async () => { + render() + + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + expect(screen.getByTestId('bundle-step')).toHaveTextContent('readyToInstall') + }) + }) + + it('should transition to uploadFailed step on upload error', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + expect(screen.getByTestId('package-step')).toHaveTextContent('uploadFailed') + }) + }) + + it('should store uniqueIdentifier after package upload', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-unique-identifier')).toHaveTextContent('test-unique-id') + }) + }) + + it('should store manifest after package upload', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-manifest-name')).toHaveTextContent('Test Plugin') + }) + }) + + it('should store error message after upload failure', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-error-msg')).toHaveTextContent('Upload failed error') + }) + }) + + it('should store dependencies after bundle upload', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2') + }) + }) + }) + + // ================================ + // Icon Processing Tests + // ================================ + describe('Icon Processing', () => { + it('should process icon URL on successful package upload', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(mockGetIconUrl).toHaveBeenCalledWith('test-icon.png') + }) + }) + + it('should process dark icon URL if provided', async () => { + const manifestWithDarkIcon = createMockManifest({ icon_dark: 'test-icon-dark.png' }) + + render() + + // Manually call the callback with dark icon manifest + if (uploadingOnPackageUploaded) { + uploadingOnPackageUploaded({ + uniqueIdentifier: 'test-id', + manifest: manifestWithDarkIcon, + }) + } + + await waitFor(() => { + expect(mockGetIconUrl).toHaveBeenCalledWith('test-icon.png') + expect(mockGetIconUrl).toHaveBeenCalledWith('test-icon-dark.png') + }) + }) + + it('should not process dark icon if not provided', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(mockGetIconUrl).toHaveBeenCalledTimes(1) + expect(mockGetIconUrl).toHaveBeenCalledWith('test-icon.png') + }) + }) + }) + + // ================================ + // Callback Tests + // ================================ + describe('Callbacks', () => { + it('should call onClose when cancel button is clicked during upload', () => { + render() + + fireEvent.click(screen.getByTestId('cancel-upload-btn')) + + expect(defaultProps.onClose).toHaveBeenCalledTimes(1) + }) + + it('should call foldAnimInto when modal close is triggered', () => { + render() + + expect(mockHideLogicState.foldAnimInto).toBeDefined() + }) + + it('should call handleStartToInstall when start install is triggered for package', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1) + }) + + it('should call handleStartToInstall when start install is triggered for bundle', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when close button is clicked in package ready-to-install', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-close-btn')) + + expect(defaultProps.onClose).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when close button is clicked in bundle ready-to-install', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-close-btn')) + + expect(defaultProps.onClose).toHaveBeenCalledTimes(1) + }) + }) + + // ================================ + // Callback Stability Tests (Memoization) + // ================================ + describe('Callback Stability', () => { + it('should maintain stable handlePackageUploaded callback reference', async () => { + const { rerender } = render() + + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + + // Rerender with same props + rerender() + + // The component should still work correctly + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + }) + + it('should maintain stable handleBundleUploaded callback reference', async () => { + const bundleProps = { ...defaultProps, file: createMockBundleFile() } + const { rerender } = render() + + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + + // Rerender with same props + rerender() + + // The component should still work correctly + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + }) + + it('should maintain stable handleUploadFail callback reference', async () => { + const { rerender } = render() + + // Rerender with same props + rerender() + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-error-msg')).toHaveTextContent('Upload failed error') + }) + }) + }) + + // ================================ + // Step Change Tests + // ================================ + describe('Step Change Handling', () => { + it('should allow step change to installed for package', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-step-installed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('installed') + }) + }) + + it('should allow step change to installFailed for package', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-step-failed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('failed') + }) + }) + + it('should allow step change to installed for bundle', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-step-installed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('bundle-step')).toHaveTextContent('installed') + }) + }) + + it('should allow step change to installFailed for bundle', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-step-failed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('bundle-step')).toHaveTextContent('failed') + }) + }) + }) + + // ================================ + // setIsInstalling Tests + // ================================ + describe('setIsInstalling Handling', () => { + it('should pass setIsInstalling to package ready-to-install', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-set-installing-false-btn')) + + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + + it('should pass setIsInstalling to bundle ready-to-install', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-set-installing-false-btn')) + + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + + // ================================ + // Error Handling Tests + // ================================ + describe('Error Handling', () => { + it('should handle onError callback for package', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-set-error-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-error-msg')).toHaveTextContent('Custom error message') + }) + }) + + it('should preserve error message through step changes', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-error-msg')).toHaveTextContent('Upload failed error') + }) + + // Error message should still be accessible + expect(screen.getByTestId('package-error-msg')).toHaveTextContent('Upload failed error') + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle file with .difypkg extension as package', () => { + const pkgFile = createMockFile('my-plugin.difypkg') + render() + + expect(screen.getByTestId('is-bundle')).toHaveTextContent('false') + }) + + it('should handle file with .difybndl extension as bundle', () => { + const bundleFile = createMockFile('my-bundle.difybndl') + render() + + expect(screen.getByTestId('is-bundle')).toHaveTextContent('true') + }) + + it('should handle file without standard extension as package', () => { + const otherFile = createMockFile('plugin.zip') + render() + + expect(screen.getByTestId('is-bundle')).toHaveTextContent('false') + }) + + it('should handle empty dependencies array for bundle', async () => { + render() + + // Manually trigger with empty dependencies + if (uploadingOnBundleUploaded) { + uploadingOnBundleUploaded([]) + } + + await waitFor(() => { + expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('0') + }) + }) + + it('should handle manifest without icon_dark', async () => { + const manifestWithoutDarkIcon = createMockManifest({ icon_dark: undefined }) + + render() + + if (uploadingOnPackageUploaded) { + uploadingOnPackageUploaded({ + uniqueIdentifier: 'test-id', + manifest: manifestWithoutDarkIcon, + }) + } + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + // Should only call getIconUrl once for the main icon + expect(mockGetIconUrl).toHaveBeenCalledTimes(1) + }) + + it('should display correct file name in uploading step', () => { + const customFile = createMockFile('custom-plugin-name.difypkg') + render() + + expect(screen.getByTestId('file-name')).toHaveTextContent('custom-plugin-name.difypkg') + }) + + it('should handle rapid state transitions', async () => { + render() + + // Quickly trigger upload success + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + // Quickly trigger step changes + fireEvent.click(screen.getByTestId('package-step-installed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('installed') + }) + }) + }) + + // ================================ + // Conditional Rendering Tests + // ================================ + describe('Conditional Rendering', () => { + it('should show uploading step initially and hide after upload', async () => { + render() + + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.queryByTestId('uploading-step')).not.toBeInTheDocument() + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + }) + + it('should render ReadyToInstallPackage for package files', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + expect(screen.queryByTestId('ready-to-install-bundle')).not.toBeInTheDocument() + }) + }) + + it('should render ReadyToInstallBundle for bundle files', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + expect(screen.queryByTestId('ready-to-install-package')).not.toBeInTheDocument() + }) + }) + + it('should render both uploading and ready-to-install simultaneously during transition', async () => { + render() + + // Initially only uploading is shown + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + // After upload, only ready-to-install is shown + await waitFor(() => { + expect(screen.queryByTestId('uploading-step')).not.toBeInTheDocument() + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + }) + }) + + // ================================ + // Data Flow Tests + // ================================ + describe('Data Flow', () => { + it('should pass correct uniqueIdentifier to ReadyToInstallPackage', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-unique-identifier')).toHaveTextContent('test-unique-id') + }) + }) + + it('should pass processed manifest to ReadyToInstallPackage', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-manifest-name')).toHaveTextContent('Test Plugin') + }) + }) + + it('should pass all dependencies to ReadyToInstallBundle', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2') + }) + }) + + it('should pass error message to ReadyToInstallPackage', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-error-msg')).toHaveTextContent('Upload failed error') + }) + }) + + it('should pass null uniqueIdentifier when not uploaded for package', () => { + render() + + // Before upload, uniqueIdentifier should be null + // The uploading step is shown, so ReadyToInstallPackage is not rendered yet + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + }) + + it('should pass null manifest when not uploaded for package', () => { + render() + + // Before upload, manifest should be null + // The uploading step is shown, so ReadyToInstallPackage is not rendered yet + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + }) + }) + + // ================================ + // Prop Variations Tests + // ================================ + describe('Prop Variations', () => { + it('should work with different file names', () => { + const files = [ + createMockFile('plugin-a.difypkg'), + createMockFile('plugin-b.difypkg'), + createMockFile('bundle-c.difybndl'), + ] + + files.forEach((file) => { + const { unmount } = render() + expect(screen.getByTestId('file-name')).toHaveTextContent(file.name) + unmount() + }) + }) + + it('should call different onClose handlers correctly', () => { + const onClose1 = vi.fn() + const onClose2 = vi.fn() + + const { rerender } = render() + + fireEvent.click(screen.getByTestId('cancel-upload-btn')) + expect(onClose1).toHaveBeenCalledTimes(1) + expect(onClose2).not.toHaveBeenCalled() + + rerender() + + fireEvent.click(screen.getByTestId('cancel-upload-btn')) + expect(onClose2).toHaveBeenCalledTimes(1) + }) + + it('should handle different file types correctly', () => { + // Package file + const { rerender } = render() + expect(screen.getByTestId('is-bundle')).toHaveTextContent('false') + + // Bundle file + rerender() + expect(screen.getByTestId('is-bundle')).toHaveTextContent('true') + }) + }) + + // ================================ + // getTitle Callback Tests + // ================================ + describe('getTitle Callback', () => { + it('should return correct title for all InstallStep values', async () => { + render() + + // uploading step - shows installPlugin + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + + // uploadFailed step + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + await waitFor(() => { + expect(screen.getByText('plugin.installModal.uploadFailed')).toBeInTheDocument() + }) + }) + + it('should differentiate bundle and package installed titles', async () => { + // Package installed title + const { unmount } = render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + fireEvent.click(screen.getByTestId('package-step-installed-btn')) + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installedSuccessfully')).toBeInTheDocument() + }) + + // Unmount and create fresh instance for bundle + unmount() + + // Bundle installed title + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + fireEvent.click(screen.getByTestId('bundle-step-installed-btn')) + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + }) + }) + }) + + // ================================ + // Integration with useHideLogic Tests + // ================================ + describe('Integration with useHideLogic', () => { + it('should use modalClassName from useHideLogic', () => { + render() + + // The hook is called and provides modalClassName + expect(mockHideLogicState.modalClassName).toBe('test-modal-class') + }) + + it('should use foldAnimInto as modal onClose handler', () => { + render() + + // The foldAnimInto function is available from the hook + expect(mockHideLogicState.foldAnimInto).toBeDefined() + }) + + it('should use handleStartToInstall from useHideLogic', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalled() + }) + + it('should use setIsInstalling from useHideLogic', async () => { + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-set-installing-false-btn')) + + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + + // ================================ + // useGetIcon Integration Tests + // ================================ + describe('Integration with useGetIcon', () => { + it('should call getIconUrl when processing manifest icon', async () => { + mockGetIconUrl.mockReturnValue('https://example.com/icon.png') + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(mockGetIconUrl).toHaveBeenCalledWith('test-icon.png') + }) + }) + + it('should handle getIconUrl for both icon and icon_dark', async () => { + mockGetIconUrl.mockReturnValue('https://example.com/icon.png') + + render() + + const manifestWithDarkIcon = createMockManifest({ + icon: 'light-icon.png', + icon_dark: 'dark-icon.png', + }) + + if (uploadingOnPackageUploaded) { + uploadingOnPackageUploaded({ + uniqueIdentifier: 'test-id', + manifest: manifestWithDarkIcon, + }) + } + + await waitFor(() => { + expect(mockGetIconUrl).toHaveBeenCalledWith('light-icon.png') + expect(mockGetIconUrl).toHaveBeenCalledWith('dark-icon.png') + }) + }) + }) +}) + +// ================================================================ +// ReadyToInstall Component Tests +// ================================================================ +describe('ReadyToInstall', () => { + // Import the actual ReadyToInstall component for isolated testing + // We'll test it through the parent component with specific scenarios + + const mockRefreshPluginList = vi.fn() + + // Reset mocks for ReadyToInstall tests + beforeEach(() => { + vi.clearAllMocks() + mockRefreshPluginList.mockClear() + }) + + describe('Step Conditional Rendering', () => { + it('should render Install component when step is readyToInstall', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + // Trigger package upload to transition to readyToInstall step + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + expect(screen.getByTestId('package-step')).toHaveTextContent('readyToInstall') + }) + }) + + it('should render Installed component when step is uploadFailed', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + // Trigger upload failure + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('uploadFailed') + }) + }) + + it('should render Installed component when step is installed', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + // Trigger package upload then install + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-step-installed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('installed') + }) + }) + + it('should render Installed component when step is installFailed', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + // Trigger package upload then fail + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-step-failed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('failed') + }) + }) + }) + + describe('handleInstalled Callback', () => { + it('should transition to installed step when handleInstalled is called', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + // Simulate successful installation + fireEvent.click(screen.getByTestId('package-step-installed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('installed') + }) + }) + + it('should call setIsInstalling(false) when installation completes', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-set-installing-false-btn')) + + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + + describe('handleFailed Callback', () => { + it('should transition to installFailed step when handleFailed is called', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-step-failed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('failed') + }) + }) + + it('should store error message when handleFailed is called with errorMsg', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-set-error-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-error-msg')).toHaveTextContent('Custom error message') + }) + }) + }) + + describe('onClose Handler', () => { + it('should call onClose when cancel is clicked', async () => { + const onClose = vi.fn() + const defaultProps = { + file: createMockFile(), + onClose, + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-close-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('Props Passing', () => { + it('should pass uniqueIdentifier to Install component', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-unique-identifier')).toHaveTextContent('test-unique-id') + }) + }) + + it('should pass manifest to Install component', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-manifest-name')).toHaveTextContent('Test Plugin') + }) + }) + + it('should pass errorMsg to Installed component', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-error-msg')).toHaveTextContent('Upload failed error') + }) + }) + }) +}) + +// ================================================================ +// Uploading Step Component Tests +// ================================================================ +describe('Uploading Step', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetIconUrl.mockReturnValue('processed-icon-url') + mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), + } + }) + + describe('Rendering', () => { + it('should render uploading state with file name', () => { + const defaultProps = { + file: createMockFile('my-custom-plugin.difypkg'), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + expect(screen.getByTestId('file-name')).toHaveTextContent('my-custom-plugin.difypkg') + }) + + it('should pass isBundle=true for bundle files', () => { + const defaultProps = { + file: createMockBundleFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + expect(screen.getByTestId('is-bundle')).toHaveTextContent('true') + }) + + it('should pass isBundle=false for package files', () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + expect(screen.getByTestId('is-bundle')).toHaveTextContent('false') + }) + }) + + describe('Upload Callbacks', () => { + it('should call onPackageUploaded with correct data for package files', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-unique-identifier')).toHaveTextContent('test-unique-id') + expect(screen.getByTestId('package-manifest-name')).toHaveTextContent('Test Plugin') + }) + }) + + it('should call onBundleUploaded with dependencies for bundle files', async () => { + const defaultProps = { + file: createMockBundleFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2') + }) + }) + + it('should call onFailed with error message when upload fails', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-error-msg')).toHaveTextContent('Upload failed error') + }) + }) + }) + + describe('Cancel Button', () => { + it('should call onCancel when cancel button is clicked', () => { + const onClose = vi.fn() + const defaultProps = { + file: createMockFile(), + onClose, + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('cancel-upload-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('File Type Detection', () => { + it('should detect .difypkg as package', () => { + const defaultProps = { + file: createMockFile('test.difypkg'), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + expect(screen.getByTestId('is-bundle')).toHaveTextContent('false') + }) + + it('should detect .difybndl as bundle', () => { + const defaultProps = { + file: createMockFile('test.difybndl'), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + expect(screen.getByTestId('is-bundle')).toHaveTextContent('true') + }) + + it('should detect other extensions as package', () => { + const defaultProps = { + file: createMockFile('test.zip'), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + expect(screen.getByTestId('is-bundle')).toHaveTextContent('false') + }) + }) +}) + +// ================================================================ +// Install Step Component Tests +// ================================================================ +describe('Install Step', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetIconUrl.mockReturnValue('processed-icon-url') + mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), + } + }) + + describe('Props Handling', () => { + it('should receive uniqueIdentifier prop correctly', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-unique-identifier')).toHaveTextContent('test-unique-id') + }) + }) + + it('should receive payload prop correctly', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-manifest-name')).toHaveTextContent('Test Plugin') + }) + }) + }) + + describe('Installation Callbacks', () => { + it('should call onStartToInstall when install starts', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1) + }) + + it('should call onInstalled when installation succeeds', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-step-installed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('installed') + }) + }) + + it('should call onFailed when installation fails', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-step-failed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('failed') + }) + }) + }) + + describe('Cancel Handling', () => { + it('should call onCancel when cancel is clicked', async () => { + const onClose = vi.fn() + const defaultProps = { + file: createMockFile(), + onClose, + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-close-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) +}) + +// ================================================================ +// Bundle ReadyToInstall Component Tests +// ================================================================ +describe('Bundle ReadyToInstall', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetIconUrl.mockReturnValue('processed-icon-url') + mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), + } + }) + + describe('Rendering', () => { + it('should render bundle install view with all plugins', async () => { + const defaultProps = { + file: createMockBundleFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2') + }) + }) + }) + + describe('Step Changes', () => { + it('should transition to installed step on successful bundle install', async () => { + const defaultProps = { + file: createMockBundleFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-step-installed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('bundle-step')).toHaveTextContent('installed') + }) + }) + + it('should transition to installFailed step on bundle install failure', async () => { + const defaultProps = { + file: createMockBundleFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-step-failed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('bundle-step')).toHaveTextContent('failed') + }) + }) + }) + + describe('Callbacks', () => { + it('should call onStartToInstall when bundle install starts', async () => { + const defaultProps = { + file: createMockBundleFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1) + }) + + it('should call setIsInstalling when bundle installation state changes', async () => { + const defaultProps = { + file: createMockBundleFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-set-installing-false-btn')) + + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + + it('should call onClose when bundle install is cancelled', async () => { + const onClose = vi.fn() + const defaultProps = { + file: createMockBundleFile(), + onClose, + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-close-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('Dependencies Handling', () => { + it('should pass all dependencies to bundle install component', async () => { + const defaultProps = { + file: createMockBundleFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2') + }) + }) + + it('should handle empty dependencies array', async () => { + const defaultProps = { + file: createMockBundleFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + // Manually trigger with empty dependencies + const callback = uploadingOnBundleUploaded + if (callback) { + act(() => { + callback([]) + }) + } + + await waitFor(() => { + expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('0') + }) + }) + }) +}) + +// ================================================================ +// Complete Flow Integration Tests +// ================================================================ +describe('Complete Installation Flows', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetIconUrl.mockReturnValue('processed-icon-url') + mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), + } + }) + + describe('Package Installation Flow', () => { + it('should complete full package installation flow: upload -> install -> success', async () => { + const onClose = vi.fn() + const onSuccess = vi.fn() + const defaultProps = { file: createMockFile(), onClose, onSuccess } + + render() + + // Step 1: Uploading + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + + // Step 2: Upload complete, transition to readyToInstall + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + expect(screen.getByTestId('package-step')).toHaveTextContent('readyToInstall') + }) + + // Step 3: Start installation + fireEvent.click(screen.getByTestId('package-start-install-btn')) + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalled() + + // Step 4: Installation complete + fireEvent.click(screen.getByTestId('package-step-installed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('installed') + expect(screen.getByText('plugin.installModal.installedSuccessfully')).toBeInTheDocument() + }) + }) + + it('should handle package installation failure flow', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + // Upload + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + // Set error and fail + fireEvent.click(screen.getByTestId('package-set-error-btn')) + fireEvent.click(screen.getByTestId('package-step-failed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('failed') + expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument() + }) + }) + + it('should handle upload failure flow', async () => { + const defaultProps = { + file: createMockFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-upload-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('package-step')).toHaveTextContent('uploadFailed') + expect(screen.getByTestId('package-error-msg')).toHaveTextContent('Upload failed error') + expect(screen.getByText('plugin.installModal.uploadFailed')).toBeInTheDocument() + }) + }) + }) + + describe('Bundle Installation Flow', () => { + it('should complete full bundle installation flow: upload -> install -> success', async () => { + const onClose = vi.fn() + const onSuccess = vi.fn() + const defaultProps = { file: createMockBundleFile(), onClose, onSuccess } + + render() + + // Step 1: Uploading + expect(screen.getByTestId('uploading-step')).toBeInTheDocument() + expect(screen.getByTestId('is-bundle')).toHaveTextContent('true') + + // Step 2: Upload complete, transition to readyToInstall + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + expect(screen.getByTestId('bundle-step')).toHaveTextContent('readyToInstall') + expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2') + }) + + // Step 3: Start installation + fireEvent.click(screen.getByTestId('bundle-start-install-btn')) + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalled() + + // Step 4: Installation complete + fireEvent.click(screen.getByTestId('bundle-step-installed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('bundle-step')).toHaveTextContent('installed') + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + }) + }) + + it('should handle bundle installation failure flow', async () => { + const defaultProps = { + file: createMockBundleFile(), + onClose: vi.fn(), + onSuccess: vi.fn(), + } + + render() + + // Upload + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + // Fail + fireEvent.click(screen.getByTestId('bundle-step-failed-btn')) + + await waitFor(() => { + expect(screen.getByTestId('bundle-step')).toHaveTextContent('failed') + expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument() + }) + }) + }) + + describe('User Cancellation Flows', () => { + it('should allow cancellation during upload', () => { + const onClose = vi.fn() + const defaultProps = { + file: createMockFile(), + onClose, + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('cancel-upload-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should allow cancellation during package ready-to-install', async () => { + const onClose = vi.fn() + const defaultProps = { + file: createMockFile(), + onClose, + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-package-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-package')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('package-close-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should allow cancellation during bundle ready-to-install', async () => { + const onClose = vi.fn() + const defaultProps = { + file: createMockBundleFile(), + onClose, + onSuccess: vi.fn(), + } + + render() + + fireEvent.click(screen.getByTestId('trigger-bundle-upload-btn')) + + await waitFor(() => { + expect(screen.getByTestId('ready-to-install-bundle')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('bundle-close-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.spec.tsx new file mode 100644 index 0000000000..6597cccd9b --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-from-local-package/ready-to-install.spec.tsx @@ -0,0 +1,471 @@ +import type { PluginDeclaration } from '../../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { InstallStep, PluginCategoryEnum } from '../../types' +import ReadyToInstall from './ready-to-install' + +// Factory function for test data +const createMockManifest = (overrides: Partial = {}): PluginDeclaration => ({ + plugin_unique_identifier: 'test-plugin-uid', + version: '1.0.0', + author: 'test-author', + icon: 'test-icon.png', + name: 'Test Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'], + description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'], + created_at: '2024-01-01T00:00:00Z', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], + ...overrides, +}) + +// Mock external dependencies +const mockRefreshPluginList = vi.fn() +vi.mock('../hooks/use-refresh-plugin-list', () => ({ + default: () => ({ + refreshPluginList: mockRefreshPluginList, + }), +})) + +// Mock Install component +let _installOnInstalled: ((notRefresh?: boolean) => void) | null = null +let _installOnFailed: ((message?: string) => void) | null = null +let _installOnCancel: (() => void) | null = null +let _installOnStartToInstall: (() => void) | null = null + +vi.mock('./steps/install', () => ({ + default: ({ + uniqueIdentifier, + payload, + onCancel, + onStartToInstall, + onInstalled, + onFailed, + }: { + uniqueIdentifier: string + payload: PluginDeclaration + onCancel: () => void + onStartToInstall?: () => void + onInstalled: (notRefresh?: boolean) => void + onFailed: (message?: string) => void + }) => { + _installOnInstalled = onInstalled + _installOnFailed = onFailed + _installOnCancel = onCancel + _installOnStartToInstall = onStartToInstall ?? null + return ( +
+ {uniqueIdentifier} + {payload.name} + + + + + + +
+ ) + }, +})) + +// Mock Installed component +vi.mock('../base/installed', () => ({ + default: ({ + payload, + isFailed, + errMsg, + onCancel, + }: { + payload: PluginDeclaration | null + isFailed: boolean + errMsg: string | null + onCancel: () => void + }) => ( +
+ {payload?.name || 'null'} + {isFailed ? 'true' : 'false'} + {errMsg || 'null'} + +
+ ), +})) + +describe('ReadyToInstall', () => { + const defaultProps = { + step: InstallStep.readyToInstall, + onStepChange: vi.fn(), + onStartToInstall: vi.fn(), + setIsInstalling: vi.fn(), + onClose: vi.fn(), + uniqueIdentifier: 'test-unique-identifier', + manifest: createMockManifest(), + errorMsg: null as string | null, + onError: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + _installOnInstalled = null + _installOnFailed = null + _installOnCancel = null + _installOnStartToInstall = null + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render Install component when step is readyToInstall', () => { + render() + + expect(screen.getByTestId('install-step')).toBeInTheDocument() + expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument() + }) + + it('should render Installed component when step is uploadFailed', () => { + render() + + expect(screen.queryByTestId('install-step')).not.toBeInTheDocument() + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + + it('should render Installed component when step is installed', () => { + render() + + expect(screen.queryByTestId('install-step')).not.toBeInTheDocument() + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + + it('should render Installed component when step is installFailed', () => { + render() + + expect(screen.queryByTestId('install-step')).not.toBeInTheDocument() + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + }) + + // ================================ + // Props Passing Tests + // ================================ + describe('Props Passing', () => { + it('should pass uniqueIdentifier to Install component', () => { + render() + + expect(screen.getByTestId('install-uid')).toHaveTextContent('custom-uid') + }) + + it('should pass manifest to Install component', () => { + const manifest = createMockManifest({ name: 'Custom Plugin' }) + render() + + expect(screen.getByTestId('install-payload-name')).toHaveTextContent('Custom Plugin') + }) + + it('should pass manifest to Installed component', () => { + const manifest = createMockManifest({ name: 'Installed Plugin' }) + render() + + expect(screen.getByTestId('installed-payload-name')).toHaveTextContent('Installed Plugin') + }) + + it('should pass errorMsg to Installed component', () => { + render( + , + ) + + expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('Some error') + }) + + it('should pass isFailed=true for uploadFailed step', () => { + render() + + expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true') + }) + + it('should pass isFailed=true for installFailed step', () => { + render() + + expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true') + }) + + it('should pass isFailed=false for installed step', () => { + render() + + expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('false') + }) + }) + + // ================================ + // handleInstalled Callback Tests + // ================================ + describe('handleInstalled Callback', () => { + it('should call onStepChange with installed when handleInstalled is triggered', () => { + const onStepChange = vi.fn() + render() + + fireEvent.click(screen.getByTestId('install-installed-btn')) + + expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed) + }) + + it('should call refreshPluginList when handleInstalled is triggered without notRefresh', () => { + const manifest = createMockManifest() + render() + + fireEvent.click(screen.getByTestId('install-installed-btn')) + + expect(mockRefreshPluginList).toHaveBeenCalledWith(manifest) + }) + + it('should not call refreshPluginList when handleInstalled is triggered with notRefresh=true', () => { + render() + + fireEvent.click(screen.getByTestId('install-installed-no-refresh-btn')) + + expect(mockRefreshPluginList).not.toHaveBeenCalled() + }) + + it('should call setIsInstalling(false) when handleInstalled is triggered', () => { + const setIsInstalling = vi.fn() + render() + + fireEvent.click(screen.getByTestId('install-installed-btn')) + + expect(setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + + // ================================ + // handleFailed Callback Tests + // ================================ + describe('handleFailed Callback', () => { + it('should call onStepChange with installFailed when handleFailed is triggered', () => { + const onStepChange = vi.fn() + render() + + fireEvent.click(screen.getByTestId('install-failed-btn')) + + expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed) + }) + + it('should call setIsInstalling(false) when handleFailed is triggered', () => { + const setIsInstalling = vi.fn() + render() + + fireEvent.click(screen.getByTestId('install-failed-btn')) + + expect(setIsInstalling).toHaveBeenCalledWith(false) + }) + + it('should call onError when handleFailed is triggered with error message', () => { + const onError = vi.fn() + render() + + fireEvent.click(screen.getByTestId('install-failed-msg-btn')) + + expect(onError).toHaveBeenCalledWith('Error message') + }) + + it('should not call onError when handleFailed is triggered without error message', () => { + const onError = vi.fn() + render() + + fireEvent.click(screen.getByTestId('install-failed-btn')) + + expect(onError).not.toHaveBeenCalled() + }) + }) + + // ================================ + // onClose Callback Tests + // ================================ + describe('onClose Callback', () => { + it('should call onClose when cancel is clicked in Install component', () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByTestId('install-cancel-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when cancel is clicked in Installed component', () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByTestId('installed-cancel-btn')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + // ================================ + // onStartToInstall Callback Tests + // ================================ + describe('onStartToInstall Callback', () => { + it('should pass onStartToInstall to Install component', () => { + const onStartToInstall = vi.fn() + render() + + fireEvent.click(screen.getByTestId('install-start-btn')) + + expect(onStartToInstall).toHaveBeenCalledTimes(1) + }) + }) + + // ================================ + // Step Transitions Tests + // ================================ + describe('Step Transitions', () => { + it('should handle transition from readyToInstall to installed', () => { + const onStepChange = vi.fn() + const { rerender } = render( + , + ) + + // Initially shows Install component + expect(screen.getByTestId('install-step')).toBeInTheDocument() + + // Simulate successful installation + fireEvent.click(screen.getByTestId('install-installed-btn')) + + expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed) + + // Rerender with new step + rerender() + + // Now shows Installed component + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + + it('should handle transition from readyToInstall to installFailed', () => { + const onStepChange = vi.fn() + const { rerender } = render( + , + ) + + // Initially shows Install component + expect(screen.getByTestId('install-step')).toBeInTheDocument() + + // Simulate failed installation + fireEvent.click(screen.getByTestId('install-failed-btn')) + + expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed) + + // Rerender with new step + rerender() + + // Now shows Installed component with failed state + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true') + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle null manifest', () => { + render() + + expect(screen.getByTestId('installed-payload-name')).toHaveTextContent('null') + }) + + it('should handle null errorMsg', () => { + render() + + expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('null') + }) + + it('should handle empty string errorMsg', () => { + render() + + expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('null') + }) + }) + + // ================================ + // Callback Stability Tests + // ================================ + describe('Callback Stability', () => { + it('should maintain stable handleInstalled callback across re-renders', () => { + const onStepChange = vi.fn() + const setIsInstalling = vi.fn() + const { rerender } = render( + , + ) + + // Rerender with same props + rerender( + , + ) + + // Callback should still work + fireEvent.click(screen.getByTestId('install-installed-btn')) + + expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed) + expect(setIsInstalling).toHaveBeenCalledWith(false) + }) + + it('should maintain stable handleFailed callback across re-renders', () => { + const onStepChange = vi.fn() + const setIsInstalling = vi.fn() + const onError = vi.fn() + const { rerender } = render( + , + ) + + // Rerender with same props + rerender( + , + ) + + // Callback should still work + fireEvent.click(screen.getByTestId('install-failed-msg-btn')) + + expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed) + expect(setIsInstalling).toHaveBeenCalledWith(false) + expect(onError).toHaveBeenCalledWith('Error message') + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx new file mode 100644 index 0000000000..9a5aa1e3b5 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx @@ -0,0 +1,622 @@ +import type { PluginDeclaration } from '../../../types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, TaskStatus } from '../../../types' +import Install from './install' + +// Factory function for test data +const createMockManifest = (overrides: Partial = {}): PluginDeclaration => ({ + plugin_unique_identifier: 'test-plugin-uid', + version: '1.0.0', + author: 'test-author', + icon: 'test-icon.png', + name: 'Test Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'], + description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'], + created_at: '2024-01-01T00:00:00Z', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0', minimum_dify_version: '0.8.0' }, + trigger: {} as PluginDeclaration['trigger'], + ...overrides, +}) + +// Mock external dependencies +const mockUseCheckInstalled = vi.fn() +vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ + default: () => mockUseCheckInstalled(), +})) + +const mockInstallPackageFromLocal = vi.fn() +vi.mock('@/service/use-plugins', () => ({ + useInstallPackageFromLocal: () => ({ + mutateAsync: mockInstallPackageFromLocal, + }), + usePluginTaskList: () => ({ + handleRefetch: vi.fn(), + }), +})) + +const mockUninstallPlugin = vi.fn() +vi.mock('@/service/plugins', () => ({ + uninstallPlugin: (...args: unknown[]) => mockUninstallPlugin(...args), +})) + +const mockCheck = vi.fn() +const mockStop = vi.fn() +vi.mock('../../base/check-task-status', () => ({ + default: () => ({ + check: mockCheck, + stop: mockStop, + }), +})) + +const mockLangGeniusVersionInfo = { current_version: '1.0.0' } +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + langGeniusVersionInfo: mockLangGeniusVersionInfo, + }), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (params) { + return `${key}:${JSON.stringify(params)}` + } + return key + }, + }), + Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record }) => ( + + {i18nKey} + {components?.trustSource} + + ), +})) + +vi.mock('../../../card', () => ({ + default: ({ payload, titleLeft }: { + payload: Record + titleLeft?: React.ReactNode + }) => ( +
+ {payload?.name as string} +
{titleLeft}
+
+ ), +})) + +vi.mock('../../base/version', () => ({ + default: ({ hasInstalled, installedVersion, toInstallVersion }: { + hasInstalled: boolean + installedVersion?: string + toInstallVersion: string + }) => ( +
+ {hasInstalled ? 'true' : 'false'} + {installedVersion || 'null'} + {toInstallVersion} +
+ ), +})) + +vi.mock('../../utils', () => ({ + pluginManifestToCardPluginProps: (manifest: PluginDeclaration) => ({ + name: manifest.name, + author: manifest.author, + version: manifest.version, + }), +})) + +describe('Install', () => { + const defaultProps = { + uniqueIdentifier: 'test-unique-identifier', + payload: createMockManifest(), + onCancel: vi.fn(), + onStartToInstall: vi.fn(), + onInstalled: vi.fn(), + onFailed: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockUseCheckInstalled.mockReturnValue({ + installedInfo: null, + isLoading: false, + }) + mockInstallPackageFromLocal.mockReset() + mockUninstallPlugin.mockReset() + mockCheck.mockReset() + mockStop.mockReset() + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render ready to install message', () => { + render() + + expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument() + }) + + it('should render trust source message', () => { + render() + + expect(screen.getByTestId('trans')).toBeInTheDocument() + }) + + it('should render plugin card', () => { + render() + + expect(screen.getByTestId('card')).toBeInTheDocument() + expect(screen.getByTestId('card-name')).toHaveTextContent('Test Plugin') + }) + + it('should render cancel button', () => { + render() + + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() + }) + + it('should render install button', () => { + render() + + expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).toBeInTheDocument() + }) + + it('should show version component when not loading', () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: null, + isLoading: false, + }) + + render() + + expect(screen.getByTestId('version')).toBeInTheDocument() + }) + + it('should not show version component when loading', () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: null, + isLoading: true, + }) + + render() + + expect(screen.queryByTestId('version')).not.toBeInTheDocument() + }) + }) + + // ================================ + // Version Display Tests + // ================================ + describe('Version Display', () => { + it('should display toInstallVersion from payload', () => { + const payload = createMockManifest({ version: '2.0.0' }) + mockUseCheckInstalled.mockReturnValue({ + installedInfo: null, + isLoading: false, + }) + + render() + + expect(screen.getByTestId('version-to-install')).toHaveTextContent('2.0.0') + }) + + it('should display hasInstalled=false when not installed', () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: null, + isLoading: false, + }) + + render() + + expect(screen.getByTestId('version-has-installed')).toHaveTextContent('false') + }) + + it('should display hasInstalled=true when already installed', () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: { + 'test-author/Test Plugin': { + installedVersion: '0.9.0', + installedId: 'installed-id', + uniqueIdentifier: 'old-uid', + }, + }, + isLoading: false, + }) + + render() + + expect(screen.getByTestId('version-has-installed')).toHaveTextContent('true') + expect(screen.getByTestId('version-installed')).toHaveTextContent('0.9.0') + }) + }) + + // ================================ + // Install Button State Tests + // ================================ + describe('Install Button State', () => { + it('should disable install button when loading', () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: null, + isLoading: true, + }) + + render() + + expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).toBeDisabled() + }) + + it('should enable install button when not loading', () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: null, + isLoading: false, + }) + + render() + + expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).not.toBeDisabled() + }) + }) + + // ================================ + // Cancel Button Tests + // ================================ + describe('Cancel Button', () => { + it('should call onCancel and stop when cancel button is clicked', () => { + const onCancel = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(mockStop).toHaveBeenCalled() + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should hide cancel button when installing', async () => { + mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {})) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument() + }) + }) + }) + + // ================================ + // Installation Flow Tests + // ================================ + describe('Installation Flow', () => { + it('should call onStartToInstall when install button is clicked', async () => { + mockInstallPackageFromLocal.mockResolvedValue({ + all_installed: true, + task_id: 'task-123', + }) + + const onStartToInstall = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + expect(onStartToInstall).toHaveBeenCalledTimes(1) + }) + }) + + it('should call onInstalled when all_installed is true', async () => { + mockInstallPackageFromLocal.mockResolvedValue({ + all_installed: true, + task_id: 'task-123', + }) + + const onInstalled = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + expect(onInstalled).toHaveBeenCalled() + }) + }) + + it('should check task status when all_installed is false', async () => { + mockInstallPackageFromLocal.mockResolvedValue({ + all_installed: false, + task_id: 'task-123', + }) + mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null }) + + const onInstalled = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + expect(mockCheck).toHaveBeenCalledWith({ + taskId: 'task-123', + pluginUniqueIdentifier: 'test-unique-identifier', + }) + }) + + await waitFor(() => { + expect(onInstalled).toHaveBeenCalledWith(true) + }) + }) + + it('should call onFailed when task status is failed', async () => { + mockInstallPackageFromLocal.mockResolvedValue({ + all_installed: false, + task_id: 'task-123', + }) + mockCheck.mockResolvedValue({ status: TaskStatus.failed, error: 'Task failed error' }) + + const onFailed = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith('Task failed error') + }) + }) + + it('should uninstall existing plugin before installing new version', async () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: { + 'test-author/Test Plugin': { + installedVersion: '0.9.0', + installedId: 'installed-id-to-uninstall', + uniqueIdentifier: 'old-uid', + }, + }, + isLoading: false, + }) + mockUninstallPlugin.mockResolvedValue({}) + mockInstallPackageFromLocal.mockResolvedValue({ + all_installed: true, + task_id: 'task-123', + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('installed-id-to-uninstall') + }) + + await waitFor(() => { + expect(mockInstallPackageFromLocal).toHaveBeenCalled() + }) + }) + }) + + // ================================ + // Error Handling Tests + // ================================ + describe('Error Handling', () => { + it('should call onFailed with error string', async () => { + mockInstallPackageFromLocal.mockRejectedValue('Installation error string') + + const onFailed = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith('Installation error string') + }) + }) + + it('should call onFailed without message when error is not string', async () => { + mockInstallPackageFromLocal.mockRejectedValue({ code: 'ERROR' }) + + const onFailed = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith() + }) + }) + }) + + // ================================ + // Auto Install Behavior Tests + // ================================ + describe('Auto Install Behavior', () => { + it('should call onInstalled when already installed with same uniqueIdentifier', async () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: { + 'test-author/Test Plugin': { + installedVersion: '1.0.0', + installedId: 'installed-id', + uniqueIdentifier: 'test-unique-identifier', + }, + }, + isLoading: false, + }) + + const onInstalled = vi.fn() + render() + + await waitFor(() => { + expect(onInstalled).toHaveBeenCalled() + }) + }) + + it('should not auto-call onInstalled when uniqueIdentifier differs', () => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: { + 'test-author/Test Plugin': { + installedVersion: '1.0.0', + installedId: 'installed-id', + uniqueIdentifier: 'different-uid', + }, + }, + isLoading: false, + }) + + const onInstalled = vi.fn() + render() + + // Should not be called immediately + expect(onInstalled).not.toHaveBeenCalled() + }) + }) + + // ================================ + // Dify Version Compatibility Tests + // ================================ + describe('Dify Version Compatibility', () => { + it('should not show warning when dify version is compatible', () => { + mockLangGeniusVersionInfo.current_version = '1.0.0' + const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '0.8.0' } }) + + render() + + expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument() + }) + + it('should show warning when dify version is incompatible', () => { + mockLangGeniusVersionInfo.current_version = '1.0.0' + const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } }) + + render() + + expect(screen.getByText(/plugin.difyVersionNotCompatible/)).toBeInTheDocument() + }) + + it('should be compatible when minimum_dify_version is undefined', () => { + mockLangGeniusVersionInfo.current_version = '1.0.0' + const payload = createMockManifest({ meta: { version: '1.0.0' } }) + + render() + + expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument() + }) + + it('should be compatible when current_version is empty', () => { + mockLangGeniusVersionInfo.current_version = '' + const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } }) + + render() + + // When current_version is empty, should be compatible (no warning) + expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument() + }) + + it('should be compatible when current_version is undefined', () => { + mockLangGeniusVersionInfo.current_version = undefined as unknown as string + const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } }) + + render() + + // When current_version is undefined, should be compatible (no warning) + expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument() + }) + }) + + // ================================ + // Installing State Tests + // ================================ + describe('Installing State', () => { + it('should show installing text when installing', async () => { + mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {})) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument() + }) + }) + + it('should disable install button when installing', async () => { + mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {})) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /plugin.installModal.installing/ })).toBeDisabled() + }) + }) + + it('should show loading spinner when installing', async () => { + mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {})) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + const spinner = document.querySelector('.animate-spin-slow') + expect(spinner).toBeInTheDocument() + }) + }) + + it('should not trigger install twice when already installing', async () => { + mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {})) + + render() + + const installButton = screen.getByRole('button', { name: 'plugin.installModal.install' }) + + // Click install + fireEvent.click(installButton) + + await waitFor(() => { + expect(mockInstallPackageFromLocal).toHaveBeenCalledTimes(1) + }) + + // Try to click again (button should be disabled but let's verify the guard works) + fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.installing/ })) + + // Should still only be called once due to isInstalling guard + expect(mockInstallPackageFromLocal).toHaveBeenCalledTimes(1) + }) + }) + + // ================================ + // Callback Props Tests + // ================================ + describe('Callback Props', () => { + it('should work without onStartToInstall callback', async () => { + mockInstallPackageFromLocal.mockResolvedValue({ + all_installed: true, + task_id: 'task-123', + }) + + const onInstalled = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' })) + + await waitFor(() => { + expect(onInstalled).toHaveBeenCalled() + }) + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx new file mode 100644 index 0000000000..18c6af8880 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx @@ -0,0 +1,346 @@ +import type { Dependency, PluginDeclaration } from '../../../types' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '../../../types' +import Uploading from './uploading' + +// Factory function for test data +const createMockManifest = (overrides: Partial = {}): PluginDeclaration => ({ + plugin_unique_identifier: 'test-plugin-uid', + version: '1.0.0', + author: 'test-author', + icon: 'test-icon.png', + name: 'Test Plugin', + category: PluginCategoryEnum.tool, + label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'], + description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'], + created_at: '2024-01-01T00:00:00Z', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: {} as PluginDeclaration['trigger'], + ...overrides, +}) + +const createMockDependencies = (): Dependency[] => [ + { + type: 'package', + value: { + unique_identifier: 'dep-1', + manifest: createMockManifest({ name: 'Dep Plugin 1' }), + }, + }, +] + +const createMockFile = (name: string = 'test-plugin.difypkg'): File => { + return new File(['test content'], name, { type: 'application/octet-stream' }) +} + +// Mock external dependencies +const mockUploadFile = vi.fn() +vi.mock('@/service/plugins', () => ({ + uploadFile: (...args: unknown[]) => mockUploadFile(...args), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (params) { + return `${key}:${JSON.stringify(params)}` + } + return key + }, + }), +})) + +vi.mock('../../../card', () => ({ + default: ({ payload, isLoading, loadingFileName }: { + payload: { name: string } + isLoading?: boolean + loadingFileName?: string + }) => ( +
+ {payload?.name} + {isLoading ? 'true' : 'false'} + {loadingFileName || 'null'} +
+ ), +})) + +describe('Uploading', () => { + const defaultProps = { + isBundle: false, + file: createMockFile(), + onCancel: vi.fn(), + onPackageUploaded: vi.fn(), + onBundleUploaded: vi.fn(), + onFailed: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockUploadFile.mockReset() + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render uploading message with file name', () => { + render() + + expect(screen.getByText(/plugin.installModal.uploadingPackage/)).toBeInTheDocument() + }) + + it('should render loading spinner', () => { + render() + + // The spinner has animate-spin-slow class + const spinner = document.querySelector('.animate-spin-slow') + expect(spinner).toBeInTheDocument() + }) + + it('should render card with loading state', () => { + render() + + expect(screen.getByTestId('card-is-loading')).toHaveTextContent('true') + }) + + it('should render card with file name', () => { + const file = createMockFile('my-plugin.difypkg') + render() + + expect(screen.getByTestId('card-name')).toHaveTextContent('my-plugin.difypkg') + expect(screen.getByTestId('card-loading-filename')).toHaveTextContent('my-plugin.difypkg') + }) + + it('should render cancel button', () => { + render() + + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() + }) + + it('should render disabled install button', () => { + render() + + const installButton = screen.getByRole('button', { name: 'plugin.installModal.install' }) + expect(installButton).toBeDisabled() + }) + }) + + // ================================ + // Upload Behavior Tests + // ================================ + describe('Upload Behavior', () => { + it('should call uploadFile on mount', async () => { + mockUploadFile.mockResolvedValue({}) + + render() + + await waitFor(() => { + expect(mockUploadFile).toHaveBeenCalledWith(defaultProps.file, false) + }) + }) + + it('should call uploadFile with isBundle=true for bundle files', async () => { + mockUploadFile.mockResolvedValue({}) + + render() + + await waitFor(() => { + expect(mockUploadFile).toHaveBeenCalledWith(defaultProps.file, true) + }) + }) + + it('should call onFailed when upload fails with error message', async () => { + const errorMessage = 'Upload failed: file too large' + mockUploadFile.mockRejectedValue({ + response: { message: errorMessage }, + }) + + const onFailed = vi.fn() + render() + + await waitFor(() => { + expect(onFailed).toHaveBeenCalledWith(errorMessage) + }) + }) + + it('should call onPackageUploaded when package upload succeeds (no error message)', async () => { + const mockResult = { + unique_identifier: 'test-uid', + manifest: createMockManifest(), + } + mockUploadFile.mockRejectedValue({ + response: mockResult, + }) + + const onPackageUploaded = vi.fn() + render( + , + ) + + await waitFor(() => { + expect(onPackageUploaded).toHaveBeenCalledWith({ + uniqueIdentifier: mockResult.unique_identifier, + manifest: mockResult.manifest, + }) + }) + }) + + it('should call onBundleUploaded when bundle upload succeeds (no error message)', async () => { + const mockDependencies = createMockDependencies() + mockUploadFile.mockRejectedValue({ + response: mockDependencies, + }) + + const onBundleUploaded = vi.fn() + render( + , + ) + + await waitFor(() => { + expect(onBundleUploaded).toHaveBeenCalledWith(mockDependencies) + }) + }) + }) + + // ================================ + // Cancel Button Tests + // ================================ + describe('Cancel Button', () => { + it('should call onCancel when cancel button is clicked', async () => { + const user = userEvent.setup() + const onCancel = vi.fn() + render() + + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + }) + + // ================================ + // File Name Display Tests + // ================================ + describe('File Name Display', () => { + it('should display correct file name for package file', () => { + const file = createMockFile('custom-plugin.difypkg') + render() + + expect(screen.getByTestId('card-name')).toHaveTextContent('custom-plugin.difypkg') + }) + + it('should display correct file name for bundle file', () => { + const file = createMockFile('custom-bundle.difybndl') + render() + + expect(screen.getByTestId('card-name')).toHaveTextContent('custom-bundle.difybndl') + }) + + it('should display file name in uploading message', () => { + const file = createMockFile('special-plugin.difypkg') + render() + + // The message includes the file name as a parameter + expect(screen.getByText(/plugin\.installModal\.uploadingPackage/)).toHaveTextContent('special-plugin.difypkg') + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty response gracefully', async () => { + mockUploadFile.mockRejectedValue({ + response: {}, + }) + + const onPackageUploaded = vi.fn() + render() + + await waitFor(() => { + expect(onPackageUploaded).toHaveBeenCalledWith({ + uniqueIdentifier: undefined, + manifest: undefined, + }) + }) + }) + + it('should handle response with only unique_identifier', async () => { + mockUploadFile.mockRejectedValue({ + response: { unique_identifier: 'only-uid' }, + }) + + const onPackageUploaded = vi.fn() + render() + + await waitFor(() => { + expect(onPackageUploaded).toHaveBeenCalledWith({ + uniqueIdentifier: 'only-uid', + manifest: undefined, + }) + }) + }) + + it('should handle file with special characters in name', () => { + const file = createMockFile('my plugin (v1.0).difypkg') + render() + + expect(screen.getByTestId('card-name')).toHaveTextContent('my plugin (v1.0).difypkg') + }) + }) + + // ================================ + // Props Variations Tests + // ================================ + describe('Props Variations', () => { + it('should work with different file types', () => { + const files = [ + createMockFile('plugin-a.difypkg'), + createMockFile('plugin-b.zip'), + createMockFile('bundle.difybndl'), + ] + + files.forEach((file) => { + const { unmount } = render() + expect(screen.getByTestId('card-name')).toHaveTextContent(file.name) + unmount() + }) + }) + + it('should pass isBundle=false to uploadFile for package files', async () => { + mockUploadFile.mockResolvedValue({}) + + render() + + await waitFor(() => { + expect(mockUploadFile).toHaveBeenCalledWith(expect.anything(), false) + }) + }) + + it('should pass isBundle=true to uploadFile for bundle files', async () => { + mockUploadFile.mockResolvedValue({}) + + render() + + await waitFor(() => { + expect(mockUploadFile).toHaveBeenCalledWith(expect.anything(), true) + }) + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/index.spec.tsx new file mode 100644 index 0000000000..b844c14147 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/index.spec.tsx @@ -0,0 +1,928 @@ +import type { Dependency, Plugin, PluginManifestInMarket } from '../../types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { InstallStep, PluginCategoryEnum } from '../../types' +import InstallFromMarketplace from './index' + +// Factory functions for test data +// Use type casting to avoid strict locale requirements in tests +const createMockManifest = (overrides: Partial = {}): PluginManifestInMarket => ({ + plugin_unique_identifier: 'test-unique-identifier', + name: 'Test Plugin', + org: 'test-org', + icon: 'test-icon.png', + label: { en_US: 'Test Plugin' } as PluginManifestInMarket['label'], + category: PluginCategoryEnum.tool, + version: '1.0.0', + latest_version: '1.0.0', + brief: { en_US: 'A test plugin' } as PluginManifestInMarket['brief'], + introduction: 'Introduction text', + verified: true, + install_count: 100, + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +const createMockPlugin = (overrides: Partial = {}): Plugin => ({ + type: 'plugin', + org: 'test-org', + name: 'Test Plugin', + plugin_id: 'test-plugin-id', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'test-package-id', + icon: 'test-icon.png', + verified: true, + label: { en_US: 'Test Plugin' }, + brief: { en_US: 'A test plugin' }, + description: { en_US: 'A test plugin description' }, + introduction: 'Introduction text', + repository: 'https://github.com/test/plugin', + category: PluginCategoryEnum.tool, + install_count: 100, + endpoint: { settings: [] }, + tags: [], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +const createMockDependencies = (): Dependency[] => [ + { + type: 'github', + value: { + repo: 'test/plugin1', + version: 'v1.0.0', + package: 'plugin1.zip', + }, + }, + { + type: 'marketplace', + value: { + plugin_unique_identifier: 'plugin-2-uid', + }, + }, +] + +// Mock external dependencies +const mockRefreshPluginList = vi.fn() +vi.mock('../hooks/use-refresh-plugin-list', () => ({ + default: () => ({ refreshPluginList: mockRefreshPluginList }), +})) + +let mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), +} +vi.mock('../hooks/use-hide-logic', () => ({ + default: () => mockHideLogicState, +})) + +// Mock child components +vi.mock('./steps/install', () => ({ + default: ({ + uniqueIdentifier, + payload, + onCancel, + onInstalled, + onFailed, + onStartToInstall, + }: { + uniqueIdentifier: string + payload: PluginManifestInMarket | Plugin + onCancel: () => void + onInstalled: (notRefresh?: boolean) => void + onFailed: (message?: string) => void + onStartToInstall: () => void + }) => ( +
+ {uniqueIdentifier} + {payload?.name} + + + + + + +
+ ), +})) + +vi.mock('../install-bundle/ready-to-install', () => ({ + default: ({ + step, + onStepChange, + onStartToInstall, + setIsInstalling, + onClose, + allPlugins, + isFromMarketPlace, + }: { + step: InstallStep + onStepChange: (step: InstallStep) => void + onStartToInstall: () => void + setIsInstalling: (isInstalling: boolean) => void + onClose: () => void + allPlugins: Dependency[] + isFromMarketPlace?: boolean + }) => ( +
+ {step} + {allPlugins?.length || 0} + {isFromMarketPlace ? 'true' : 'false'} + + + + + + +
+ ), +})) + +vi.mock('../base/installed', () => ({ + default: ({ + payload, + isMarketPayload, + isFailed, + errMsg, + onCancel, + }: { + payload: PluginManifestInMarket | Plugin | null + isMarketPayload?: boolean + isFailed: boolean + errMsg?: string | null + onCancel: () => void + }) => ( +
+ {payload?.name || 'no-payload'} + {isMarketPayload ? 'true' : 'false'} + {isFailed ? 'true' : 'false'} + {errMsg || 'no-error'} + +
+ ), +})) + +describe('InstallFromMarketplace', () => { + const defaultProps = { + uniqueIdentifier: 'test-unique-identifier', + manifest: createMockManifest(), + onSuccess: vi.fn(), + onClose: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockHideLogicState = { + modalClassName: 'test-modal-class', + foldAnimInto: vi.fn(), + setIsInstalling: vi.fn(), + handleStartToInstall: vi.fn(), + } + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render modal with correct initial state for single plugin', () => { + render() + + expect(screen.getByTestId('install-step')).toBeInTheDocument() + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should render with bundle step when isBundle is true', () => { + const dependencies = createMockDependencies() + render( + , + ) + + expect(screen.getByTestId('bundle-step')).toBeInTheDocument() + expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2') + }) + + it('should pass isFromMarketPlace as true to bundle component', () => { + const dependencies = createMockDependencies() + render( + , + ) + + expect(screen.getByTestId('is-from-marketplace')).toHaveTextContent('true') + }) + + it('should pass correct props to Install component', () => { + render() + + expect(screen.getByTestId('unique-identifier')).toHaveTextContent('test-unique-identifier') + expect(screen.getByTestId('payload-name')).toHaveTextContent('Test Plugin') + }) + + it('should apply modal className from useHideLogic', () => { + expect(mockHideLogicState.modalClassName).toBe('test-modal-class') + }) + }) + + // ================================ + // Title Display Tests + // ================================ + describe('Title Display', () => { + it('should show install title in readyToInstall step', () => { + render() + + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should show success title when installation completes for single plugin', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installedSuccessfully')).toBeInTheDocument() + }) + }) + + it('should show bundle complete title when bundle installation completes', async () => { + const dependencies = createMockDependencies() + render( + , + ) + + fireEvent.click(screen.getByTestId('bundle-change-to-installed')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + }) + }) + + it('should show failed title when installation fails', async () => { + render() + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument() + }) + }) + }) + + // ================================ + // State Management Tests + // ================================ + describe('State Management', () => { + it('should transition from readyToInstall to installed on success', async () => { + render() + + expect(screen.getByTestId('install-step')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('false') + }) + }) + + it('should transition from readyToInstall to installFailed on failure', async () => { + render() + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + expect(screen.getByTestId('error-msg')).toHaveTextContent('Installation failed') + }) + }) + + it('should handle failure without error message', async () => { + render() + + fireEvent.click(screen.getByTestId('install-fail-no-msg-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + expect(screen.getByTestId('error-msg')).toHaveTextContent('no-error') + }) + }) + + it('should update step via onStepChange in bundle mode', async () => { + const dependencies = createMockDependencies() + render( + , + ) + + fireEvent.click(screen.getByTestId('bundle-change-to-installed')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + }) + }) + }) + + // ================================ + // Callback Stability Tests (Memoization) + // ================================ + describe('Callback Stability', () => { + it('should maintain stable getTitle callback across rerenders', () => { + const { rerender } = render() + + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + + rerender() + + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should maintain stable handleInstalled callback', async () => { + const { rerender } = render() + + rerender() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + }) + + it('should maintain stable handleFailed callback', async () => { + const { rerender } = render() + + rerender() + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + }) + }) + }) + + // ================================ + // User Interactions Tests + // ================================ + describe('User Interactions', () => { + it('should call onClose when cancel is clicked', () => { + render() + + fireEvent.click(screen.getByTestId('cancel-btn')) + + expect(defaultProps.onClose).toHaveBeenCalledTimes(1) + }) + + it('should call foldAnimInto when modal close is triggered', () => { + render() + + expect(mockHideLogicState.foldAnimInto).toBeDefined() + }) + + it('should call handleStartToInstall when start install is triggered', () => { + render() + + fireEvent.click(screen.getByTestId('start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1) + }) + + it('should call onSuccess when close button is clicked in installed step', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('installed-close-btn')) + + expect(defaultProps.onSuccess).toHaveBeenCalledTimes(1) + }) + + it('should call onClose in bundle mode cancel', () => { + const dependencies = createMockDependencies() + render( + , + ) + + fireEvent.click(screen.getByTestId('bundle-cancel-btn')) + + expect(defaultProps.onClose).toHaveBeenCalledTimes(1) + }) + }) + + // ================================ + // Refresh Plugin List Tests + // ================================ + describe('Refresh Plugin List', () => { + it('should call refreshPluginList when installation completes without notRefresh flag', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(mockRefreshPluginList).toHaveBeenCalledWith(defaultProps.manifest) + }) + }) + + it('should not call refreshPluginList when notRefresh flag is true', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-no-refresh-btn')) + + await waitFor(() => { + expect(mockRefreshPluginList).not.toHaveBeenCalled() + }) + }) + }) + + // ================================ + // setIsInstalling Tests + // ================================ + describe('setIsInstalling Behavior', () => { + it('should call setIsInstalling(false) when installation completes', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + + it('should call setIsInstalling(false) when installation fails', async () => { + render() + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + + it('should pass setIsInstalling to bundle component', () => { + const dependencies = createMockDependencies() + render( + , + ) + + fireEvent.click(screen.getByTestId('bundle-set-installing-true')) + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(true) + + fireEvent.click(screen.getByTestId('bundle-set-installing-false')) + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + + // ================================ + // Installed Component Props Tests + // ================================ + describe('Installed Component Props', () => { + it('should pass isMarketPayload as true to Installed component', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('is-market-payload')).toHaveTextContent('true') + }) + }) + + it('should pass correct payload to Installed component', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-payload')).toHaveTextContent('Test Plugin') + }) + }) + + it('should pass isFailed as true when installation fails', async () => { + render() + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + }) + }) + + it('should pass error message to Installed component on failure', async () => { + render() + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('error-msg')).toHaveTextContent('Installation failed') + }) + }) + }) + + // ================================ + // Prop Variations Tests + // ================================ + describe('Prop Variations', () => { + it('should work with Plugin type manifest', () => { + const plugin = createMockPlugin() + render( + , + ) + + expect(screen.getByTestId('payload-name')).toHaveTextContent('Test Plugin') + }) + + it('should work with PluginManifestInMarket type manifest', () => { + const manifest = createMockManifest({ name: 'Market Plugin' }) + render( + , + ) + + expect(screen.getByTestId('payload-name')).toHaveTextContent('Market Plugin') + }) + + it('should handle different uniqueIdentifier values', () => { + render( + , + ) + + expect(screen.getByTestId('unique-identifier')).toHaveTextContent('custom-unique-id-123') + }) + + it('should work without isBundle prop (default to single plugin)', () => { + render() + + expect(screen.getByTestId('install-step')).toBeInTheDocument() + expect(screen.queryByTestId('bundle-step')).not.toBeInTheDocument() + }) + + it('should work with isBundle=false', () => { + render( + , + ) + + expect(screen.getByTestId('install-step')).toBeInTheDocument() + expect(screen.queryByTestId('bundle-step')).not.toBeInTheDocument() + }) + + it('should work with empty dependencies array in bundle mode', () => { + render( + , + ) + + expect(screen.getByTestId('bundle-step')).toBeInTheDocument() + expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('0') + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle manifest with minimal required fields', () => { + const minimalManifest = createMockManifest({ + name: 'Minimal', + version: '0.0.1', + }) + render( + , + ) + + expect(screen.getByTestId('payload-name')).toHaveTextContent('Minimal') + }) + + it('should handle multiple rapid state transitions', async () => { + render() + + // Trigger installation completion + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + + // Should stay in installed state + expect(screen.getByTestId('is-failed')).toHaveTextContent('false') + }) + + it('should handle bundle mode step changes', async () => { + const dependencies = createMockDependencies() + render( + , + ) + + // Change to installed step + fireEvent.click(screen.getByTestId('bundle-change-to-installed')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + }) + }) + + it('should handle bundle mode failure step change', async () => { + const dependencies = createMockDependencies() + render( + , + ) + + fireEvent.click(screen.getByTestId('bundle-change-to-failed')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument() + }) + }) + + it('should not render Install component in terminal steps', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.queryByTestId('install-step')).not.toBeInTheDocument() + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + }) + + it('should render Installed component for success state with isFailed false', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('false') + }) + }) + + it('should render Installed component for failure state with isFailed true', async () => { + render() + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + }) + }) + }) + + // ================================ + // Terminal Steps Rendering Tests + // ================================ + describe('Terminal Steps Rendering', () => { + it('should render Installed component when step is installed', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + }) + }) + + it('should render Installed component when step is installFailed', async () => { + render() + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByTestId('installed-step')).toBeInTheDocument() + expect(screen.getByTestId('is-failed')).toHaveTextContent('true') + }) + }) + + it('should not render Install component when in terminal step', async () => { + render() + + // Initially Install is shown + expect(screen.getByTestId('install-step')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.queryByTestId('install-step')).not.toBeInTheDocument() + }) + }) + }) + + // ================================ + // Data Flow Tests + // ================================ + describe('Data Flow', () => { + it('should pass uniqueIdentifier to Install component', () => { + render() + + expect(screen.getByTestId('unique-identifier')).toHaveTextContent('flow-test-id') + }) + + it('should pass manifest payload to Install component', () => { + const customManifest = createMockManifest({ name: 'Flow Test Plugin' }) + render() + + expect(screen.getByTestId('payload-name')).toHaveTextContent('Flow Test Plugin') + }) + + it('should pass dependencies to bundle component', () => { + const dependencies = createMockDependencies() + render( + , + ) + + expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2') + }) + + it('should pass current step to bundle component', () => { + const dependencies = createMockDependencies() + render( + , + ) + + expect(screen.getByTestId('bundle-step-value')).toHaveTextContent(InstallStep.readyToInstall) + }) + }) + + // ================================ + // Manifest Category Variations Tests + // ================================ + describe('Manifest Category Variations', () => { + it('should handle tool category manifest', () => { + const manifest = createMockManifest({ category: PluginCategoryEnum.tool }) + render() + + expect(screen.getByTestId('install-step')).toBeInTheDocument() + }) + + it('should handle model category manifest', () => { + const manifest = createMockManifest({ category: PluginCategoryEnum.model }) + render() + + expect(screen.getByTestId('install-step')).toBeInTheDocument() + }) + + it('should handle extension category manifest', () => { + const manifest = createMockManifest({ category: PluginCategoryEnum.extension }) + render() + + expect(screen.getByTestId('install-step')).toBeInTheDocument() + }) + }) + + // ================================ + // Hook Integration Tests + // ================================ + describe('Hook Integration', () => { + it('should use handleStartToInstall from useHideLogic', () => { + render() + + fireEvent.click(screen.getByTestId('start-install-btn')) + + expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalled() + }) + + it('should use setIsInstalling from useHideLogic in handleInstalled', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + + it('should use setIsInstalling from useHideLogic in handleFailed', async () => { + render() + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false) + }) + }) + + it('should use refreshPluginList from useRefreshPluginList', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(mockRefreshPluginList).toHaveBeenCalled() + }) + }) + }) + + // ================================ + // getTitle Memoization Tests + // ================================ + describe('getTitle Memoization', () => { + it('should return installPlugin title for readyToInstall step', () => { + render() + + expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument() + }) + + it('should return installedSuccessfully for non-bundle installed step', async () => { + render() + + fireEvent.click(screen.getByTestId('install-success-btn')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installedSuccessfully')).toBeInTheDocument() + }) + }) + + it('should return installComplete for bundle installed step', async () => { + const dependencies = createMockDependencies() + render( + , + ) + + fireEvent.click(screen.getByTestId('bundle-change-to-installed')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument() + }) + }) + + it('should return installFailed for installFailed step', async () => { + render() + + fireEvent.click(screen.getByTestId('install-fail-btn')) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.spec.tsx new file mode 100644 index 0000000000..6727a431b4 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.spec.tsx @@ -0,0 +1,729 @@ +import type { Plugin, PluginManifestInMarket } from '../../../types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, TaskStatus } from '../../../types' +import Install from './install' + +// Factory functions for test data +const createMockManifest = (overrides: Partial = {}): PluginManifestInMarket => ({ + plugin_unique_identifier: 'test-unique-identifier', + name: 'Test Plugin', + org: 'test-org', + icon: 'test-icon.png', + label: { en_US: 'Test Plugin' } as PluginManifestInMarket['label'], + category: PluginCategoryEnum.tool, + version: '1.0.0', + latest_version: '1.0.0', + brief: { en_US: 'A test plugin' } as PluginManifestInMarket['brief'], + introduction: 'Introduction text', + verified: true, + install_count: 100, + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +const createMockPlugin = (overrides: Partial = {}): Plugin => ({ + type: 'plugin', + org: 'test-org', + name: 'Test Plugin', + plugin_id: 'test-plugin-id', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'test-package-id', + icon: 'test-icon.png', + verified: true, + label: { en_US: 'Test Plugin' }, + brief: { en_US: 'A test plugin' }, + description: { en_US: 'A test plugin description' }, + introduction: 'Introduction text', + repository: 'https://github.com/test/plugin', + category: PluginCategoryEnum.tool, + install_count: 100, + endpoint: { settings: [] }, + tags: [], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +// Mock variables for controlling test behavior +let mockInstalledInfo: Record | undefined +let mockIsLoading = false +const mockInstallPackageFromMarketPlace = vi.fn() +const mockUpdatePackageFromMarketPlace = vi.fn() +const mockCheckTaskStatus = vi.fn() +const mockStopTaskStatus = vi.fn() +const mockHandleRefetch = vi.fn() +let mockPluginDeclaration: { manifest: { meta: { minimum_dify_version: string } } } | undefined +let mockCanInstall = true +let mockLangGeniusVersionInfo = { current_version: '1.0.0' } + +// Mock useCheckInstalled +vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ + default: ({ pluginIds }: { pluginIds: string[], enabled: boolean }) => ({ + installedInfo: mockInstalledInfo, + isLoading: mockIsLoading, + error: null, + }), +})) + +// Mock service hooks +vi.mock('@/service/use-plugins', () => ({ + useInstallPackageFromMarketPlace: () => ({ + mutateAsync: mockInstallPackageFromMarketPlace, + }), + useUpdatePackageFromMarketPlace: () => ({ + mutateAsync: mockUpdatePackageFromMarketPlace, + }), + usePluginDeclarationFromMarketPlace: () => ({ + data: mockPluginDeclaration, + }), + usePluginTaskList: () => ({ + handleRefetch: mockHandleRefetch, + }), +})) + +// Mock checkTaskStatus +vi.mock('../../base/check-task-status', () => ({ + default: () => ({ + check: mockCheckTaskStatus, + stop: mockStopTaskStatus, + }), +})) + +// Mock useAppContext +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + langGeniusVersionInfo: mockLangGeniusVersionInfo, + }), +})) + +// Mock useInstallPluginLimit +vi.mock('../../hooks/use-install-plugin-limit', () => ({ + default: () => ({ canInstall: mockCanInstall }), +})) + +// Mock Card component +vi.mock('../../../card', () => ({ + default: ({ payload, titleLeft, className, limitedInstall }: { + payload: any + titleLeft?: React.ReactNode + className?: string + limitedInstall?: boolean + }) => ( +
+ {payload?.name} + {limitedInstall ? 'true' : 'false'} + {titleLeft &&
{titleLeft}
} +
+ ), +})) + +// Mock Version component +vi.mock('../../base/version', () => ({ + default: ({ hasInstalled, installedVersion, toInstallVersion }: { + hasInstalled: boolean + installedVersion?: string + toInstallVersion: string + }) => ( +
+ {hasInstalled ? 'true' : 'false'} + {installedVersion || 'none'} + {toInstallVersion} +
+ ), +})) + +// Mock utils +vi.mock('../../utils', () => ({ + pluginManifestInMarketToPluginProps: (payload: PluginManifestInMarket) => ({ + name: payload.name, + icon: payload.icon, + category: payload.category, + }), +})) + +describe('Install Component (steps/install.tsx)', () => { + const defaultProps = { + uniqueIdentifier: 'test-unique-identifier', + payload: createMockManifest(), + onCancel: vi.fn(), + onStartToInstall: vi.fn(), + onInstalled: vi.fn(), + onFailed: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockInstalledInfo = undefined + mockIsLoading = false + mockPluginDeclaration = undefined + mockCanInstall = true + mockLangGeniusVersionInfo = { current_version: '1.0.0' } + mockInstallPackageFromMarketPlace.mockResolvedValue({ + all_installed: false, + task_id: 'task-123', + }) + mockUpdatePackageFromMarketPlace.mockResolvedValue({ + all_installed: false, + task_id: 'task-456', + }) + mockCheckTaskStatus.mockResolvedValue({ + status: TaskStatus.success, + }) + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render ready to install text', () => { + render() + + expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument() + }) + + it('should render plugin card with correct payload', () => { + render() + + expect(screen.getByTestId('plugin-card')).toBeInTheDocument() + expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Test Plugin') + }) + + it('should render cancel button when not installing', () => { + render() + + expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() + }) + + it('should render install button', () => { + render() + + expect(screen.getByText('plugin.installModal.install')).toBeInTheDocument() + }) + + it('should not render version component while loading', () => { + mockIsLoading = true + render() + + expect(screen.queryByTestId('version-component')).not.toBeInTheDocument() + }) + + it('should render version component when not loading', () => { + mockIsLoading = false + render() + + expect(screen.getByTestId('version-component')).toBeInTheDocument() + }) + }) + + // ================================ + // Version Display Tests + // ================================ + describe('Version Display', () => { + it('should show hasInstalled as false when not installed', () => { + mockInstalledInfo = undefined + render() + + expect(screen.getByTestId('has-installed')).toHaveTextContent('false') + }) + + it('should show hasInstalled as true when already installed', () => { + mockInstalledInfo = { + 'test-plugin-id': { + installedId: 'install-id', + installedVersion: '0.9.0', + uniqueIdentifier: 'old-unique-id', + }, + } + const plugin = createMockPlugin() + render() + + expect(screen.getByTestId('has-installed')).toHaveTextContent('true') + expect(screen.getByTestId('installed-version')).toHaveTextContent('0.9.0') + }) + + it('should show correct toInstallVersion from payload.version', () => { + const manifest = createMockManifest({ version: '2.0.0' }) + render() + + expect(screen.getByTestId('to-install-version')).toHaveTextContent('2.0.0') + }) + + it('should fallback to latest_version when version is undefined', () => { + const manifest = createMockManifest({ version: undefined as any, latest_version: '3.0.0' }) + render() + + expect(screen.getByTestId('to-install-version')).toHaveTextContent('3.0.0') + }) + }) + + // ================================ + // Version Compatibility Tests + // ================================ + describe('Version Compatibility', () => { + it('should not show warning when no plugin declaration', () => { + mockPluginDeclaration = undefined + render() + + expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument() + }) + + it('should not show warning when dify version is compatible', () => { + mockLangGeniusVersionInfo = { current_version: '2.0.0' } + mockPluginDeclaration = { + manifest: { meta: { minimum_dify_version: '1.0.0' } }, + } + render() + + expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument() + }) + + it('should show warning when dify version is incompatible', () => { + mockLangGeniusVersionInfo = { current_version: '1.0.0' } + mockPluginDeclaration = { + manifest: { meta: { minimum_dify_version: '2.0.0' } }, + } + render() + + expect(screen.getByText(/plugin.difyVersionNotCompatible/)).toBeInTheDocument() + }) + }) + + // ================================ + // Install Limit Tests + // ================================ + describe('Install Limit', () => { + it('should pass limitedInstall=false to Card when canInstall is true', () => { + mockCanInstall = true + render() + + expect(screen.getByTestId('card-limited-install')).toHaveTextContent('false') + }) + + it('should pass limitedInstall=true to Card when canInstall is false', () => { + mockCanInstall = false + render() + + expect(screen.getByTestId('card-limited-install')).toHaveTextContent('true') + }) + + it('should disable install button when canInstall is false', () => { + mockCanInstall = false + render() + + const installBtn = screen.getByText('plugin.installModal.install').closest('button') + expect(installBtn).toBeDisabled() + }) + }) + + // ================================ + // Button States Tests + // ================================ + describe('Button States', () => { + it('should disable install button when loading', () => { + mockIsLoading = true + render() + + const installBtn = screen.getByText('plugin.installModal.install').closest('button') + expect(installBtn).toBeDisabled() + }) + + it('should enable install button when not loading and canInstall', () => { + mockIsLoading = false + mockCanInstall = true + render() + + const installBtn = screen.getByText('plugin.installModal.install').closest('button') + expect(installBtn).not.toBeDisabled() + }) + }) + + // ================================ + // Cancel Button Tests + // ================================ + describe('Cancel Button', () => { + it('should call onCancel and stop when cancel is clicked', () => { + render() + + fireEvent.click(screen.getByText('common.operation.cancel')) + + expect(mockStopTaskStatus).toHaveBeenCalled() + expect(defaultProps.onCancel).toHaveBeenCalled() + }) + }) + + // ================================ + // New Installation Flow Tests + // ================================ + describe('New Installation Flow', () => { + it('should call onStartToInstall when install button is clicked', async () => { + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + expect(defaultProps.onStartToInstall).toHaveBeenCalled() + }) + + it('should call installPackageFromMarketPlace for new installation', async () => { + mockInstalledInfo = undefined + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith('test-unique-identifier') + }) + }) + + it('should call onInstalled immediately when all_installed is true', async () => { + mockInstallPackageFromMarketPlace.mockResolvedValue({ + all_installed: true, + task_id: 'task-123', + }) + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(defaultProps.onInstalled).toHaveBeenCalled() + expect(mockCheckTaskStatus).not.toHaveBeenCalled() + }) + }) + + it('should check task status when all_installed is false', async () => { + mockInstallPackageFromMarketPlace.mockResolvedValue({ + all_installed: false, + task_id: 'task-123', + }) + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(mockHandleRefetch).toHaveBeenCalled() + expect(mockCheckTaskStatus).toHaveBeenCalledWith({ + taskId: 'task-123', + pluginUniqueIdentifier: 'test-unique-identifier', + }) + }) + }) + + it('should call onInstalled with true when task succeeds', async () => { + mockCheckTaskStatus.mockResolvedValue({ status: TaskStatus.success }) + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(defaultProps.onInstalled).toHaveBeenCalledWith(true) + }) + }) + + it('should call onFailed when task fails', async () => { + mockCheckTaskStatus.mockResolvedValue({ + status: TaskStatus.failed, + error: 'Task failed error', + }) + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(defaultProps.onFailed).toHaveBeenCalledWith('Task failed error') + }) + }) + }) + + // ================================ + // Update Installation Flow Tests + // ================================ + describe('Update Installation Flow', () => { + beforeEach(() => { + mockInstalledInfo = { + 'test-plugin-id': { + installedId: 'install-id', + installedVersion: '0.9.0', + uniqueIdentifier: 'old-unique-id', + }, + } + }) + + it('should call updatePackageFromMarketPlace for update installation', async () => { + const plugin = createMockPlugin() + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(mockUpdatePackageFromMarketPlace).toHaveBeenCalledWith({ + original_plugin_unique_identifier: 'old-unique-id', + new_plugin_unique_identifier: 'test-unique-identifier', + }) + }) + }) + + it('should not call installPackageFromMarketPlace when updating', async () => { + const plugin = createMockPlugin() + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(mockInstallPackageFromMarketPlace).not.toHaveBeenCalled() + }) + }) + }) + + // ================================ + // Auto-Install on Already Installed Tests + // ================================ + describe('Auto-Install on Already Installed', () => { + it('should call onInstalled when already installed with same uniqueIdentifier', async () => { + mockInstalledInfo = { + 'test-plugin-id': { + installedId: 'install-id', + installedVersion: '1.0.0', + uniqueIdentifier: 'test-unique-identifier', + }, + } + const plugin = createMockPlugin() + render() + + await waitFor(() => { + expect(defaultProps.onInstalled).toHaveBeenCalled() + }) + }) + + it('should not auto-install when uniqueIdentifier differs', async () => { + mockInstalledInfo = { + 'test-plugin-id': { + installedId: 'install-id', + installedVersion: '1.0.0', + uniqueIdentifier: 'different-unique-id', + }, + } + const plugin = createMockPlugin() + render() + + // Wait a bit to ensure onInstalled is not called + await new Promise(resolve => setTimeout(resolve, 100)) + expect(defaultProps.onInstalled).not.toHaveBeenCalled() + }) + }) + + // ================================ + // Error Handling Tests + // ================================ + describe('Error Handling', () => { + it('should call onFailed with string error', async () => { + mockInstallPackageFromMarketPlace.mockRejectedValue('String error message') + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(defaultProps.onFailed).toHaveBeenCalledWith('String error message') + }) + }) + + it('should call onFailed without message for non-string error', async () => { + mockInstallPackageFromMarketPlace.mockRejectedValue(new Error('Error object')) + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(defaultProps.onFailed).toHaveBeenCalledWith() + }) + }) + }) + + // ================================ + // Installing State Tests + // ================================ + describe('Installing State', () => { + it('should hide cancel button while installing', async () => { + // Make the install take some time + mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {})) + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument() + }) + }) + + it('should show installing text while installing', async () => { + mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {})) + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument() + }) + }) + + it('should disable install button while installing', async () => { + mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {})) + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + const installBtn = screen.getByText('plugin.installModal.installing').closest('button') + expect(installBtn).toBeDisabled() + }) + }) + + it('should not trigger multiple installs when clicking rapidly', async () => { + mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {})) + render() + + const installBtn = screen.getByText('plugin.installModal.install').closest('button')! + + await act(async () => { + fireEvent.click(installBtn) + }) + + // Wait for the button to be disabled + await waitFor(() => { + expect(installBtn).toBeDisabled() + }) + + // Try clicking again - should not trigger another install + await act(async () => { + fireEvent.click(installBtn) + fireEvent.click(installBtn) + }) + + expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledTimes(1) + }) + }) + + // ================================ + // Prop Variations Tests + // ================================ + describe('Prop Variations', () => { + it('should work with PluginManifestInMarket payload', () => { + const manifest = createMockManifest({ name: 'Manifest Plugin' }) + render() + + expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Manifest Plugin') + }) + + it('should work with Plugin payload', () => { + const plugin = createMockPlugin({ name: 'Plugin Type' }) + render() + + expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Plugin Type') + }) + + it('should work without onStartToInstall callback', async () => { + const propsWithoutCallback = { + ...defaultProps, + onStartToInstall: undefined, + } + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + // Should not throw and should proceed with installation + await waitFor(() => { + expect(mockInstallPackageFromMarketPlace).toHaveBeenCalled() + }) + }) + + it('should handle different uniqueIdentifier values', async () => { + render() + + await act(async () => { + fireEvent.click(screen.getByText('plugin.installModal.install')) + }) + + await waitFor(() => { + expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith('custom-id-123') + }) + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty plugin_id gracefully', () => { + const manifest = createMockManifest() + // Manifest doesn't have plugin_id, so installedInfo won't match + render() + + expect(screen.getByTestId('has-installed')).toHaveTextContent('false') + }) + + it('should handle undefined installedInfo', () => { + mockInstalledInfo = undefined + render() + + expect(screen.getByTestId('has-installed')).toHaveTextContent('false') + }) + + it('should handle null current_version in langGeniusVersionInfo', () => { + mockLangGeniusVersionInfo = { current_version: null as any } + mockPluginDeclaration = { + manifest: { meta: { minimum_dify_version: '1.0.0' } }, + } + render() + + // Should not show warning when current_version is null (defaults to compatible) + expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument() + }) + }) + + // ================================ + // Component Memoization Tests + // ================================ + describe('Component Memoization', () => { + it('should maintain stable component across rerenders with same props', () => { + const { rerender } = render() + + expect(screen.getByTestId('plugin-card')).toBeInTheDocument() + + rerender() + + expect(screen.getByTestId('plugin-card')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/description/index.spec.tsx b/web/app/components/plugins/marketplace/description/index.spec.tsx new file mode 100644 index 0000000000..b5c8cb716b --- /dev/null +++ b/web/app/components/plugins/marketplace/description/index.spec.tsx @@ -0,0 +1,683 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Import component after mocks are set up +import Description from './index' + +// ================================ +// Mock external dependencies +// ================================ + +// Track mock locale for testing +let mockDefaultLocale = 'en-US' + +// Mock translations with realistic values +const pluginTranslations: Record = { + 'marketplace.empower': 'Empower your AI development', + 'marketplace.discover': 'Discover', + 'marketplace.difyMarketplace': 'Dify Marketplace', + 'marketplace.and': 'and', + 'category.models': 'Models', + 'category.tools': 'Tools', + 'category.datasources': 'Data Sources', + 'category.triggers': 'Triggers', + 'category.agents': 'Agent Strategies', + 'category.extensions': 'Extensions', + 'category.bundles': 'Bundles', +} + +const commonTranslations: Record = { + 'operation.in': 'in', +} + +// Mock getLocaleOnServer and translate +vi.mock('@/i18n-config/server', () => ({ + getLocaleOnServer: vi.fn(() => Promise.resolve(mockDefaultLocale)), + getTranslation: vi.fn((locale: string, ns: string) => { + return Promise.resolve({ + t: (key: string) => { + if (ns === 'plugin') + return pluginTranslations[key] || key + if (ns === 'common') + return commonTranslations[key] || key + return key + }, + }) + }), +})) + +// ================================ +// Description Component Tests +// ================================ +describe('Description', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDefaultLocale = 'en-US' + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render without crashing', async () => { + const { container } = render(await Description({})) + + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render h1 heading with empower text', async () => { + render(await Description({})) + + const heading = screen.getByRole('heading', { level: 1 }) + expect(heading).toBeInTheDocument() + expect(heading).toHaveTextContent('Empower your AI development') + }) + + it('should render h2 subheading', async () => { + render(await Description({})) + + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading).toBeInTheDocument() + }) + + it('should apply correct CSS classes to h1', async () => { + render(await Description({})) + + const heading = screen.getByRole('heading', { level: 1 }) + expect(heading).toHaveClass('title-4xl-semi-bold') + expect(heading).toHaveClass('mb-2') + expect(heading).toHaveClass('text-center') + expect(heading).toHaveClass('text-text-primary') + }) + + it('should apply correct CSS classes to h2', async () => { + render(await Description({})) + + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading).toHaveClass('body-md-regular') + expect(subheading).toHaveClass('text-center') + expect(subheading).toHaveClass('text-text-tertiary') + }) + }) + + // ================================ + // Non-Chinese Locale Rendering Tests + // ================================ + describe('Non-Chinese Locale Rendering', () => { + it('should render discover text for en-US locale', async () => { + render(await Description({ locale: 'en-US' })) + + expect(screen.getByText(/Discover/)).toBeInTheDocument() + }) + + it('should render all category names', async () => { + render(await Description({ locale: 'en-US' })) + + expect(screen.getByText('Models')).toBeInTheDocument() + expect(screen.getByText('Tools')).toBeInTheDocument() + expect(screen.getByText('Data Sources')).toBeInTheDocument() + expect(screen.getByText('Triggers')).toBeInTheDocument() + expect(screen.getByText('Agent Strategies')).toBeInTheDocument() + expect(screen.getByText('Extensions')).toBeInTheDocument() + expect(screen.getByText('Bundles')).toBeInTheDocument() + }) + + it('should render "and" conjunction text', async () => { + render(await Description({ locale: 'en-US' })) + + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading.textContent).toContain('and') + }) + + it('should render "in" preposition at the end for non-Chinese locales', async () => { + render(await Description({ locale: 'en-US' })) + + expect(screen.getByText('in')).toBeInTheDocument() + }) + + it('should render Dify Marketplace text at the end for non-Chinese locales', async () => { + render(await Description({ locale: 'en-US' })) + + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading.textContent).toContain('Dify Marketplace') + }) + + it('should render category spans with styled underline effect', async () => { + const { container } = render(await Description({ locale: 'en-US' })) + + const styledSpans = container.querySelectorAll('.body-md-medium.relative.z-\\[1\\]') + // 7 category spans (models, tools, datasources, triggers, agents, extensions, bundles) + expect(styledSpans.length).toBe(7) + }) + + it('should apply text-text-secondary class to category spans', async () => { + const { container } = render(await Description({ locale: 'en-US' })) + + const styledSpans = container.querySelectorAll('.text-text-secondary') + expect(styledSpans.length).toBeGreaterThanOrEqual(7) + }) + }) + + // ================================ + // Chinese (zh-Hans) Locale Rendering Tests + // ================================ + describe('Chinese (zh-Hans) Locale Rendering', () => { + it('should render "in" text at the beginning for zh-Hans locale', async () => { + render(await Description({ locale: 'zh-Hans' })) + + // In zh-Hans mode, "in" appears at the beginning + const inElements = screen.getAllByText('in') + expect(inElements.length).toBeGreaterThanOrEqual(1) + }) + + it('should render Dify Marketplace text for zh-Hans locale', async () => { + render(await Description({ locale: 'zh-Hans' })) + + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading.textContent).toContain('Dify Marketplace') + }) + + it('should render discover text for zh-Hans locale', async () => { + render(await Description({ locale: 'zh-Hans' })) + + expect(screen.getByText(/Discover/)).toBeInTheDocument() + }) + + it('should render all categories for zh-Hans locale', async () => { + render(await Description({ locale: 'zh-Hans' })) + + expect(screen.getByText('Models')).toBeInTheDocument() + expect(screen.getByText('Tools')).toBeInTheDocument() + expect(screen.getByText('Data Sources')).toBeInTheDocument() + expect(screen.getByText('Triggers')).toBeInTheDocument() + expect(screen.getByText('Agent Strategies')).toBeInTheDocument() + expect(screen.getByText('Extensions')).toBeInTheDocument() + expect(screen.getByText('Bundles')).toBeInTheDocument() + }) + + it('should render both zh-Hans specific elements and shared elements', async () => { + render(await Description({ locale: 'zh-Hans' })) + + // zh-Hans has specific element order: "in" -> Dify Marketplace -> Discover + // then the same category list with "and" -> Bundles + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading.textContent).toContain('and') + }) + }) + + // ================================ + // Locale Prop Variations Tests + // ================================ + describe('Locale Prop Variations', () => { + it('should use default locale when locale prop is undefined', async () => { + mockDefaultLocale = 'en-US' + render(await Description({})) + + // Should use the default locale from getLocaleOnServer + expect(screen.getByText('Empower your AI development')).toBeInTheDocument() + }) + + it('should use provided locale prop instead of default', async () => { + mockDefaultLocale = 'ja-JP' + render(await Description({ locale: 'en-US' })) + + // The locale prop should be used, triggering non-Chinese rendering + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading).toBeInTheDocument() + }) + + it('should handle ja-JP locale as non-Chinese', async () => { + render(await Description({ locale: 'ja-JP' })) + + // Should render in non-Chinese format (discover first, then "in Dify Marketplace" at end) + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading.textContent).toContain('Dify Marketplace') + }) + + it('should handle ko-KR locale as non-Chinese', async () => { + render(await Description({ locale: 'ko-KR' })) + + // Should render in non-Chinese format + expect(screen.getByText('Empower your AI development')).toBeInTheDocument() + }) + + it('should handle de-DE locale as non-Chinese', async () => { + render(await Description({ locale: 'de-DE' })) + + expect(screen.getByText('Empower your AI development')).toBeInTheDocument() + }) + + it('should handle fr-FR locale as non-Chinese', async () => { + render(await Description({ locale: 'fr-FR' })) + + expect(screen.getByText('Empower your AI development')).toBeInTheDocument() + }) + + it('should handle pt-BR locale as non-Chinese', async () => { + render(await Description({ locale: 'pt-BR' })) + + expect(screen.getByText('Empower your AI development')).toBeInTheDocument() + }) + + it('should handle es-ES locale as non-Chinese', async () => { + render(await Description({ locale: 'es-ES' })) + + expect(screen.getByText('Empower your AI development')).toBeInTheDocument() + }) + }) + + // ================================ + // Conditional Rendering Tests + // ================================ + describe('Conditional Rendering', () => { + it('should render zh-Hans specific content when locale is zh-Hans', async () => { + const { container } = render(await Description({ locale: 'zh-Hans' })) + + // zh-Hans has additional span with mr-1 before "in" text at the start + const mrSpan = container.querySelector('span.mr-1') + expect(mrSpan).toBeInTheDocument() + }) + + it('should render non-Chinese specific content when locale is not zh-Hans', async () => { + render(await Description({ locale: 'en-US' })) + + // Non-Chinese has "in" and "Dify Marketplace" at the end + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading.textContent).toContain('Dify Marketplace') + }) + + it('should not render zh-Hans intro content for non-Chinese locales', async () => { + render(await Description({ locale: 'en-US' })) + + // For en-US, the order should be Discover ... in Dify Marketplace + // The "in" text should only appear once at the end + const subheading = screen.getByRole('heading', { level: 2 }) + const content = subheading.textContent || '' + + // "in" should appear after "Bundles" and before "Dify Marketplace" + const bundlesIndex = content.indexOf('Bundles') + const inIndex = content.indexOf('in') + const marketplaceIndex = content.indexOf('Dify Marketplace') + + expect(bundlesIndex).toBeLessThan(inIndex) + expect(inIndex).toBeLessThan(marketplaceIndex) + }) + + it('should render zh-Hans with proper word order', async () => { + render(await Description({ locale: 'zh-Hans' })) + + const subheading = screen.getByRole('heading', { level: 2 }) + const content = subheading.textContent || '' + + // zh-Hans order: in -> Dify Marketplace -> Discover -> categories + const inIndex = content.indexOf('in') + const marketplaceIndex = content.indexOf('Dify Marketplace') + const discoverIndex = content.indexOf('Discover') + + expect(inIndex).toBeLessThan(marketplaceIndex) + expect(marketplaceIndex).toBeLessThan(discoverIndex) + }) + }) + + // ================================ + // Category Styling Tests + // ================================ + describe('Category Styling', () => { + it('should apply underline effect with after pseudo-element styling', async () => { + const { container } = render(await Description({})) + + const categorySpan = container.querySelector('.after\\:absolute') + expect(categorySpan).toBeInTheDocument() + }) + + it('should apply correct after pseudo-element classes', async () => { + const { container } = render(await Description({})) + + // Check for the specific after pseudo-element classes + const categorySpans = container.querySelectorAll('.after\\:bottom-\\[1\\.5px\\]') + expect(categorySpans.length).toBe(7) + }) + + it('should apply full width to after element', async () => { + const { container } = render(await Description({})) + + const categorySpans = container.querySelectorAll('.after\\:w-full') + expect(categorySpans.length).toBe(7) + }) + + it('should apply correct height to after element', async () => { + const { container } = render(await Description({})) + + const categorySpans = container.querySelectorAll('.after\\:h-2') + expect(categorySpans.length).toBe(7) + }) + + it('should apply bg-text-text-selected to after element', async () => { + const { container } = render(await Description({})) + + const categorySpans = container.querySelectorAll('.after\\:bg-text-text-selected') + expect(categorySpans.length).toBe(7) + }) + + it('should have z-index 1 on category spans', async () => { + const { container } = render(await Description({})) + + const categorySpans = container.querySelectorAll('.z-\\[1\\]') + expect(categorySpans.length).toBe(7) + }) + + it('should apply left margin to category spans', async () => { + const { container } = render(await Description({})) + + const categorySpans = container.querySelectorAll('.ml-1') + expect(categorySpans.length).toBeGreaterThanOrEqual(7) + }) + + it('should apply both left and right margin to specific spans', async () => { + const { container } = render(await Description({})) + + // Extensions and Bundles spans have both ml-1 and mr-1 + const extensionsBundlesSpans = container.querySelectorAll('.ml-1.mr-1') + expect(extensionsBundlesSpans.length).toBe(2) + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty props object', async () => { + const { container } = render(await Description({})) + + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render fragment as root element', async () => { + const { container } = render(await Description({})) + + // Fragment renders h1 and h2 as direct children + expect(container.querySelector('h1')).toBeInTheDocument() + expect(container.querySelector('h2')).toBeInTheDocument() + }) + + it('should handle locale prop with undefined value', async () => { + render(await Description({ locale: undefined })) + + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument() + }) + + it('should handle zh-Hant as non-Chinese simplified', async () => { + render(await Description({ locale: 'zh-Hant' })) + + // zh-Hant is different from zh-Hans, should use non-Chinese format + const subheading = screen.getByRole('heading', { level: 2 }) + const content = subheading.textContent || '' + + // Check that "Dify Marketplace" appears at the end (non-Chinese format) + const discoverIndex = content.indexOf('Discover') + const marketplaceIndex = content.indexOf('Dify Marketplace') + + // For non-Chinese locales, Discover should come before Dify Marketplace + expect(discoverIndex).toBeLessThan(marketplaceIndex) + }) + }) + + // ================================ + // Content Structure Tests + // ================================ + describe('Content Structure', () => { + it('should have comma separators between categories', async () => { + render(await Description({})) + + const subheading = screen.getByRole('heading', { level: 2 }) + const content = subheading.textContent || '' + + // Commas should exist between categories + expect(content).toMatch(/Models[^\n\r,\u2028\u2029]*,.*Tools[^\n\r,\u2028\u2029]*,.*Data Sources[^\n\r,\u2028\u2029]*,.*Triggers[^\n\r,\u2028\u2029]*,.*Agent Strategies[^\n\r,\u2028\u2029]*,.*Extensions/) + }) + + it('should have "and" before last category (Bundles)', async () => { + render(await Description({})) + + const subheading = screen.getByRole('heading', { level: 2 }) + const content = subheading.textContent || '' + + // "and" should appear before Bundles + const andIndex = content.indexOf('and') + const bundlesIndex = content.indexOf('Bundles') + + expect(andIndex).toBeLessThan(bundlesIndex) + }) + + it('should render all text elements in correct order for en-US', async () => { + render(await Description({ locale: 'en-US' })) + + const subheading = screen.getByRole('heading', { level: 2 }) + const content = subheading.textContent || '' + + const expectedOrder = [ + 'Discover', + 'Models', + 'Tools', + 'Data Sources', + 'Triggers', + 'Agent Strategies', + 'Extensions', + 'and', + 'Bundles', + 'in', + 'Dify Marketplace', + ] + + let lastIndex = -1 + for (const text of expectedOrder) { + const currentIndex = content.indexOf(text) + expect(currentIndex).toBeGreaterThan(lastIndex) + lastIndex = currentIndex + } + }) + + it('should render all text elements in correct order for zh-Hans', async () => { + render(await Description({ locale: 'zh-Hans' })) + + const subheading = screen.getByRole('heading', { level: 2 }) + const content = subheading.textContent || '' + + // zh-Hans order: in -> Dify Marketplace -> Discover -> categories -> and -> Bundles + const inIndex = content.indexOf('in') + const marketplaceIndex = content.indexOf('Dify Marketplace') + const discoverIndex = content.indexOf('Discover') + const modelsIndex = content.indexOf('Models') + + expect(inIndex).toBeLessThan(marketplaceIndex) + expect(marketplaceIndex).toBeLessThan(discoverIndex) + expect(discoverIndex).toBeLessThan(modelsIndex) + }) + }) + + // ================================ + // Layout Tests + // ================================ + describe('Layout', () => { + it('should have shrink-0 on h1 heading', async () => { + render(await Description({})) + + const heading = screen.getByRole('heading', { level: 1 }) + expect(heading).toHaveClass('shrink-0') + }) + + it('should have shrink-0 on h2 subheading', async () => { + render(await Description({})) + + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading).toHaveClass('shrink-0') + }) + + it('should have flex layout on h2', async () => { + render(await Description({})) + + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading).toHaveClass('flex') + }) + + it('should have items-center on h2', async () => { + render(await Description({})) + + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading).toHaveClass('items-center') + }) + + it('should have justify-center on h2', async () => { + render(await Description({})) + + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading).toHaveClass('justify-center') + }) + }) + + // ================================ + // Translation Function Tests + // ================================ + describe('Translation Functions', () => { + it('should call getTranslation for plugin namespace', async () => { + const { getTranslation } = await import('@/i18n-config/server') + render(await Description({ locale: 'en-US' })) + + expect(getTranslation).toHaveBeenCalledWith('en-US', 'plugin') + }) + + it('should call getTranslation for common namespace', async () => { + const { getTranslation } = await import('@/i18n-config/server') + render(await Description({ locale: 'en-US' })) + + expect(getTranslation).toHaveBeenCalledWith('en-US', 'common') + }) + + it('should call getLocaleOnServer when locale prop is undefined', async () => { + const { getLocaleOnServer } = await import('@/i18n-config/server') + render(await Description({})) + + expect(getLocaleOnServer).toHaveBeenCalled() + }) + + it('should use locale prop when provided', async () => { + const { getTranslation } = await import('@/i18n-config/server') + render(await Description({ locale: 'ja-JP' })) + + expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'plugin') + expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'common') + }) + }) + + // ================================ + // Accessibility Tests + // ================================ + describe('Accessibility', () => { + it('should have proper heading hierarchy', async () => { + render(await Description({})) + + const h1 = screen.getByRole('heading', { level: 1 }) + const h2 = screen.getByRole('heading', { level: 2 }) + + expect(h1).toBeInTheDocument() + expect(h2).toBeInTheDocument() + }) + + it('should have readable text content', async () => { + render(await Description({})) + + const h1 = screen.getByRole('heading', { level: 1 }) + expect(h1.textContent).not.toBe('') + }) + + it('should have visible h1 heading', async () => { + render(await Description({})) + + const heading = screen.getByRole('heading', { level: 1 }) + expect(heading).toBeVisible() + }) + + it('should have visible h2 heading', async () => { + render(await Description({})) + + const subheading = screen.getByRole('heading', { level: 2 }) + expect(subheading).toBeVisible() + }) + }) +}) + +// ================================ +// Integration Tests +// ================================ +describe('Description Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDefaultLocale = 'en-US' + }) + + it('should render complete component structure', async () => { + const { container } = render(await Description({ locale: 'en-US' })) + + // Main headings + expect(container.querySelector('h1')).toBeInTheDocument() + expect(container.querySelector('h2')).toBeInTheDocument() + + // All category spans + const categorySpans = container.querySelectorAll('.body-md-medium') + expect(categorySpans.length).toBe(7) + }) + + it('should render complete zh-Hans structure', async () => { + const { container } = render(await Description({ locale: 'zh-Hans' })) + + // Main headings + expect(container.querySelector('h1')).toBeInTheDocument() + expect(container.querySelector('h2')).toBeInTheDocument() + + // All category spans + const categorySpans = container.querySelectorAll('.body-md-medium') + expect(categorySpans.length).toBe(7) + }) + + it('should correctly switch between zh-Hans and en-US layouts', async () => { + // Render en-US + const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' })) + const enContent = enContainer.querySelector('h2')?.textContent || '' + unmountEn() + + // Render zh-Hans + const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' })) + const zhContent = zhContainer.querySelector('h2')?.textContent || '' + + // Both should have all categories + expect(enContent).toContain('Models') + expect(zhContent).toContain('Models') + + // But order should differ + const enMarketplaceIndex = enContent.indexOf('Dify Marketplace') + const enDiscoverIndex = enContent.indexOf('Discover') + const zhMarketplaceIndex = zhContent.indexOf('Dify Marketplace') + const zhDiscoverIndex = zhContent.indexOf('Discover') + + // en-US: Discover comes before Dify Marketplace + expect(enDiscoverIndex).toBeLessThan(enMarketplaceIndex) + + // zh-Hans: Dify Marketplace comes before Discover + expect(zhMarketplaceIndex).toBeLessThan(zhDiscoverIndex) + }) + + it('should maintain consistent styling across locales', async () => { + // Render en-US + const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' })) + const enCategoryCount = enContainer.querySelectorAll('.body-md-medium').length + unmountEn() + + // Render zh-Hans + const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' })) + const zhCategoryCount = zhContainer.querySelectorAll('.body-md-medium').length + + // Both should have same number of styled category spans + expect(enCategoryCount).toBe(zhCategoryCount) + expect(enCategoryCount).toBe(7) + }) +}) diff --git a/web/app/components/plugins/marketplace/empty/index.spec.tsx b/web/app/components/plugins/marketplace/empty/index.spec.tsx new file mode 100644 index 0000000000..f9feced7bc --- /dev/null +++ b/web/app/components/plugins/marketplace/empty/index.spec.tsx @@ -0,0 +1,834 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Empty from './index' +import Line from './line' + +// ================================ +// Mock external dependencies only +// ================================ + +// Mock useMixedTranslation hook +vi.mock('../hooks', () => ({ + useMixedTranslation: (_locale?: string) => ({ + t: (key: string) => { + const translations: Record = { + 'plugin.marketplace.noPluginFound': 'No plugin found', + } + return translations[key] || key + }, + }), +})) + +// Mock useTheme hook with controllable theme value +let mockTheme = 'light' + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ + theme: mockTheme, + }), +})) + +// ================================ +// Line Component Tests +// ================================ +describe('Line', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = 'light' + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should render SVG element', () => { + const { container } = render() + + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg') + }) + }) + + // ================================ + // Light Theme Tests + // ================================ + describe('Light Theme', () => { + beforeEach(() => { + mockTheme = 'light' + }) + + it('should render light mode SVG', () => { + const { container } = render() + + const svg = container.querySelector('svg') + expect(svg).toHaveAttribute('width', '2') + expect(svg).toHaveAttribute('height', '241') + expect(svg).toHaveAttribute('viewBox', '0 0 2 241') + }) + + it('should render light mode path with correct d attribute', () => { + const { container } = render() + + const path = container.querySelector('path') + expect(path).toHaveAttribute('d', 'M1 0.5L1 240.5') + }) + + it('should render light mode linear gradient with correct id', () => { + const { container } = render() + + const gradient = container.querySelector('#paint0_linear_1989_74474') + expect(gradient).toBeInTheDocument() + }) + + it('should render light mode gradient with white stop colors', () => { + const { container } = render() + + const stops = container.querySelectorAll('stop') + expect(stops.length).toBe(3) + + // First stop - white with 0.01 opacity + expect(stops[0]).toHaveAttribute('stop-color', 'white') + expect(stops[0]).toHaveAttribute('stop-opacity', '0.01') + + // Middle stop - dark color with 0.08 opacity + expect(stops[1]).toHaveAttribute('stop-color', '#101828') + expect(stops[1]).toHaveAttribute('stop-opacity', '0.08') + + // Last stop - white with 0.01 opacity + expect(stops[2]).toHaveAttribute('stop-color', 'white') + expect(stops[2]).toHaveAttribute('stop-opacity', '0.01') + }) + + it('should apply className to SVG in light mode', () => { + const { container } = render() + + const svg = container.querySelector('svg') + expect(svg).toHaveClass('test-class') + }) + }) + + // ================================ + // Dark Theme Tests + // ================================ + describe('Dark Theme', () => { + beforeEach(() => { + mockTheme = 'dark' + }) + + it('should render dark mode SVG', () => { + const { container } = render() + + const svg = container.querySelector('svg') + expect(svg).toHaveAttribute('width', '2') + expect(svg).toHaveAttribute('height', '240') + expect(svg).toHaveAttribute('viewBox', '0 0 2 240') + }) + + it('should render dark mode path with correct d attribute', () => { + const { container } = render() + + const path = container.querySelector('path') + expect(path).toHaveAttribute('d', 'M1 0L1 240') + }) + + it('should render dark mode linear gradient with correct id', () => { + const { container } = render() + + const gradient = container.querySelector('#paint0_linear_6295_52176') + expect(gradient).toBeInTheDocument() + }) + + it('should render dark mode gradient stops', () => { + const { container } = render() + + const stops = container.querySelectorAll('stop') + expect(stops.length).toBe(3) + + // First stop - no color, 0.01 opacity + expect(stops[0]).toHaveAttribute('stop-opacity', '0.01') + + // Middle stop - light color with 0.14 opacity + expect(stops[1]).toHaveAttribute('stop-color', '#C8CEDA') + expect(stops[1]).toHaveAttribute('stop-opacity', '0.14') + + // Last stop - no color, 0.01 opacity + expect(stops[2]).toHaveAttribute('stop-opacity', '0.01') + }) + + it('should apply className to SVG in dark mode', () => { + const { container } = render() + + const svg = container.querySelector('svg') + expect(svg).toHaveClass('dark-test-class') + }) + }) + + // ================================ + // Props Variations Tests + // ================================ + describe('Props Variations', () => { + it('should handle undefined className', () => { + const { container } = render() + + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should handle empty string className', () => { + const { container } = render() + + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should handle multiple class names', () => { + const { container } = render() + + const svg = container.querySelector('svg') + expect(svg).toHaveClass('class-1') + expect(svg).toHaveClass('class-2') + expect(svg).toHaveClass('class-3') + }) + + it('should handle Tailwind utility classes', () => { + const { container } = render( + , + ) + + const svg = container.querySelector('svg') + expect(svg).toHaveClass('absolute') + expect(svg).toHaveClass('right-[-1px]') + expect(svg).toHaveClass('top-1/2') + expect(svg).toHaveClass('-translate-y-1/2') + }) + }) + + // ================================ + // Theme Switching Tests + // ================================ + describe('Theme Switching', () => { + it('should render different SVG dimensions based on theme', () => { + // Light mode + mockTheme = 'light' + const { container: lightContainer, unmount: unmountLight } = render() + expect(lightContainer.querySelector('svg')).toHaveAttribute('height', '241') + unmountLight() + + // Dark mode + mockTheme = 'dark' + const { container: darkContainer } = render() + expect(darkContainer.querySelector('svg')).toHaveAttribute('height', '240') + }) + + it('should use different gradient IDs based on theme', () => { + // Light mode + mockTheme = 'light' + const { container: lightContainer, unmount: unmountLight } = render() + expect(lightContainer.querySelector('#paint0_linear_1989_74474')).toBeInTheDocument() + expect(lightContainer.querySelector('#paint0_linear_6295_52176')).not.toBeInTheDocument() + unmountLight() + + // Dark mode + mockTheme = 'dark' + const { container: darkContainer } = render() + expect(darkContainer.querySelector('#paint0_linear_6295_52176')).toBeInTheDocument() + expect(darkContainer.querySelector('#paint0_linear_1989_74474')).not.toBeInTheDocument() + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle theme value of light explicitly', () => { + mockTheme = 'light' + const { container } = render() + + expect(container.querySelector('#paint0_linear_1989_74474')).toBeInTheDocument() + }) + + it('should handle non-dark theme as light mode', () => { + mockTheme = 'system' + const { container } = render() + + // Non-dark themes should use light mode SVG + expect(container.querySelector('svg')).toHaveAttribute('height', '241') + }) + + it('should render SVG with fill none', () => { + const { container } = render() + + const svg = container.querySelector('svg') + expect(svg).toHaveAttribute('fill', 'none') + }) + + it('should render path with gradient stroke', () => { + mockTheme = 'light' + const { container } = render() + + const path = container.querySelector('path') + expect(path).toHaveAttribute('stroke', 'url(#paint0_linear_1989_74474)') + }) + + it('should render dark mode path with gradient stroke', () => { + mockTheme = 'dark' + const { container } = render() + + const path = container.querySelector('path') + expect(path).toHaveAttribute('stroke', 'url(#paint0_linear_6295_52176)') + }) + }) +}) + +// ================================ +// Empty Component Tests +// ================================ +describe('Empty', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = 'light' + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render 16 placeholder cards', () => { + const { container } = render() + + const placeholderCards = container.querySelectorAll('.h-\\[144px\\]') + expect(placeholderCards.length).toBe(16) + }) + + it('should render default no plugin found text', () => { + render() + + expect(screen.getByText('No plugin found')).toBeInTheDocument() + }) + + it('should render Group icon', () => { + const { container } = render() + + // Icon wrapper should be present + const iconWrapper = container.querySelector('.h-14.w-14') + expect(iconWrapper).toBeInTheDocument() + }) + + it('should render four Line components around the icon', () => { + const { container } = render() + + // Four SVG elements from Line components + 1 Group icon SVG = 5 total + const svgs = container.querySelectorAll('svg') + expect(svgs.length).toBe(5) + }) + + it('should render center content with absolute positioning', () => { + const { container } = render() + + const centerContent = container.querySelector('.absolute.left-1\\/2.top-1\\/2') + expect(centerContent).toBeInTheDocument() + }) + }) + + // ================================ + // Text Prop Tests + // ================================ + describe('Text Prop', () => { + it('should render custom text when provided', () => { + render() + + expect(screen.getByText('Custom empty message')).toBeInTheDocument() + expect(screen.queryByText('No plugin found')).not.toBeInTheDocument() + }) + + it('should render default translation when text is empty string', () => { + render() + + expect(screen.getByText('No plugin found')).toBeInTheDocument() + }) + + it('should render default translation when text is undefined', () => { + render() + + expect(screen.getByText('No plugin found')).toBeInTheDocument() + }) + + it('should render long custom text', () => { + const longText = 'This is a very long message that describes why there are no plugins found in the current search results and what the user might want to do next to find what they are looking for' + render() + + expect(screen.getByText(longText)).toBeInTheDocument() + }) + + it('should render text with special characters', () => { + render() + + expect(screen.getByText('No plugins found for query: ')).toBeInTheDocument() + }) + }) + + // ================================ + // LightCard Prop Tests + // ================================ + describe('LightCard Prop', () => { + it('should render overlay when lightCard is false', () => { + const { container } = render() + + const overlay = container.querySelector('.bg-marketplace-plugin-empty') + expect(overlay).toBeInTheDocument() + }) + + it('should not render overlay when lightCard is true', () => { + const { container } = render() + + const overlay = container.querySelector('.bg-marketplace-plugin-empty') + expect(overlay).not.toBeInTheDocument() + }) + + it('should render overlay by default when lightCard is undefined', () => { + const { container } = render() + + const overlay = container.querySelector('.bg-marketplace-plugin-empty') + expect(overlay).toBeInTheDocument() + }) + + it('should apply light card styling to placeholder cards when lightCard is true', () => { + const { container } = render() + + const placeholderCards = container.querySelectorAll('.bg-background-default-lighter') + expect(placeholderCards.length).toBe(16) + }) + + it('should apply default styling to placeholder cards when lightCard is false', () => { + const { container } = render() + + const placeholderCards = container.querySelectorAll('.bg-background-section-burn') + expect(placeholderCards.length).toBe(16) + }) + + it('should apply opacity to light card placeholder', () => { + const { container } = render() + + const placeholderCards = container.querySelectorAll('.opacity-75') + expect(placeholderCards.length).toBe(16) + }) + }) + + // ================================ + // ClassName Prop Tests + // ================================ + describe('ClassName Prop', () => { + it('should apply custom className to container', () => { + const { container } = render() + + expect(container.querySelector('.custom-class')).toBeInTheDocument() + }) + + it('should preserve base classes when adding custom className', () => { + const { container } = render() + + const element = container.querySelector('.custom-class') + expect(element).toHaveClass('relative') + expect(element).toHaveClass('flex') + expect(element).toHaveClass('h-0') + expect(element).toHaveClass('grow') + }) + + it('should handle empty string className', () => { + const { container } = render() + + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle undefined className', () => { + const { container } = render() + + const element = container.firstChild as HTMLElement + expect(element).toHaveClass('relative') + }) + + it('should handle multiple custom classes', () => { + const { container } = render() + + const element = container.querySelector('.class-a') + expect(element).toHaveClass('class-b') + expect(element).toHaveClass('class-c') + }) + }) + + // ================================ + // Locale Prop Tests + // ================================ + describe('Locale Prop', () => { + it('should pass locale to useMixedTranslation', () => { + render() + + // Translation should still work + expect(screen.getByText('No plugin found')).toBeInTheDocument() + }) + + it('should handle undefined locale', () => { + render() + + expect(screen.getByText('No plugin found')).toBeInTheDocument() + }) + + it('should handle en-US locale', () => { + render() + + expect(screen.getByText('No plugin found')).toBeInTheDocument() + }) + + it('should handle ja-JP locale', () => { + render() + + expect(screen.getByText('No plugin found')).toBeInTheDocument() + }) + }) + + // ================================ + // Placeholder Cards Layout Tests + // ================================ + describe('Placeholder Cards Layout', () => { + it('should remove right margin on every 4th card', () => { + const { container } = render() + + const cards = container.querySelectorAll('.h-\\[144px\\]') + + // Cards at indices 3, 7, 11, 15 (4th, 8th, 12th, 16th) should have mr-0 + expect(cards[3]).toHaveClass('mr-0') + expect(cards[7]).toHaveClass('mr-0') + expect(cards[11]).toHaveClass('mr-0') + expect(cards[15]).toHaveClass('mr-0') + }) + + it('should have margin on cards that are not at the end of row', () => { + const { container } = render() + + const cards = container.querySelectorAll('.h-\\[144px\\]') + + // Cards not at row end should have mr-3 + expect(cards[0]).toHaveClass('mr-3') + expect(cards[1]).toHaveClass('mr-3') + expect(cards[2]).toHaveClass('mr-3') + }) + + it('should remove bottom margin on last row cards', () => { + const { container } = render() + + const cards = container.querySelectorAll('.h-\\[144px\\]') + + // Cards at indices 12, 13, 14, 15 should have mb-0 + expect(cards[12]).toHaveClass('mb-0') + expect(cards[13]).toHaveClass('mb-0') + expect(cards[14]).toHaveClass('mb-0') + expect(cards[15]).toHaveClass('mb-0') + }) + + it('should have bottom margin on non-last row cards', () => { + const { container } = render() + + const cards = container.querySelectorAll('.h-\\[144px\\]') + + // Cards at indices 0-11 should have mb-3 + expect(cards[0]).toHaveClass('mb-3') + expect(cards[5]).toHaveClass('mb-3') + expect(cards[11]).toHaveClass('mb-3') + }) + + it('should have correct width calculation for 4 columns', () => { + const { container } = render() + + const cards = container.querySelectorAll('.w-\\[calc\\(\\(100\\%-36px\\)\\/4\\)\\]') + expect(cards.length).toBe(16) + }) + + it('should have rounded corners on cards', () => { + const { container } = render() + + const cards = container.querySelectorAll('.rounded-xl') + // 16 cards + 1 icon wrapper = 17 rounded-xl elements + expect(cards.length).toBeGreaterThanOrEqual(16) + }) + }) + + // ================================ + // Icon Container Tests + // ================================ + describe('Icon Container', () => { + it('should render icon container with border', () => { + const { container } = render() + + const iconContainer = container.querySelector('.border-dashed') + expect(iconContainer).toBeInTheDocument() + }) + + it('should render icon container with shadow', () => { + const { container } = render() + + const iconContainer = container.querySelector('.shadow-lg') + expect(iconContainer).toBeInTheDocument() + }) + + it('should render icon container centered', () => { + const { container } = render() + + const centerWrapper = container.querySelector('.-translate-x-1\\/2.-translate-y-1\\/2') + expect(centerWrapper).toBeInTheDocument() + }) + + it('should have z-index for center content', () => { + const { container } = render() + + const centerContent = container.querySelector('.z-\\[2\\]') + expect(centerContent).toBeInTheDocument() + }) + }) + + // ================================ + // Line Positioning Tests + // ================================ + describe('Line Positioning', () => { + it('should position Line components correctly around icon', () => { + const { container } = render() + + // Right line + const rightLine = container.querySelector('.right-\\[-1px\\]') + expect(rightLine).toBeInTheDocument() + + // Left line + const leftLine = container.querySelector('.left-\\[-1px\\]') + expect(leftLine).toBeInTheDocument() + }) + + it('should have rotated Line components for top and bottom', () => { + const { container } = render() + + const rotatedLines = container.querySelectorAll('.rotate-90') + expect(rotatedLines.length).toBe(2) + }) + }) + + // ================================ + // Combined Props Tests + // ================================ + describe('Combined Props', () => { + it('should handle all props together', () => { + const { container } = render( + , + ) + + expect(screen.getByText('Custom message')).toBeInTheDocument() + expect(container.querySelector('.custom-wrapper')).toBeInTheDocument() + expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument() + }) + + it('should render correctly with lightCard false and custom text', () => { + const { container } = render( + , + ) + + expect(screen.getByText('No results')).toBeInTheDocument() + expect(container.querySelector('.bg-marketplace-plugin-empty')).toBeInTheDocument() + }) + + it('should handle className with lightCard prop', () => { + const { container } = render( + , + ) + + const element = container.querySelector('.test-class') + expect(element).toBeInTheDocument() + + // Verify light card styling is applied + const lightCards = container.querySelectorAll('.bg-background-default-lighter') + expect(lightCards.length).toBe(16) + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty props object', () => { + const { container } = render() + + expect(container.firstChild).toBeInTheDocument() + expect(screen.getByText('No plugin found')).toBeInTheDocument() + }) + + it('should render with only text prop', () => { + render() + + expect(screen.getByText('Only text')).toBeInTheDocument() + }) + + it('should render with only lightCard prop', () => { + const { container } = render() + + expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument() + }) + + it('should render with only className prop', () => { + const { container } = render() + + expect(container.querySelector('.only-class')).toBeInTheDocument() + }) + + it('should render with only locale prop', () => { + render() + + expect(screen.getByText('No plugin found')).toBeInTheDocument() + }) + + it('should handle text with unicode characters', () => { + render() + + expect(screen.getByText('没有找到插件 🔍')).toBeInTheDocument() + }) + + it('should handle text with HTML entities', () => { + render() + + expect(screen.getByText('No plugins & no results')).toBeInTheDocument() + }) + + it('should handle whitespace-only text', () => { + const { container } = render() + + // Whitespace-only text is truthy, so it should be rendered + const textContainer = container.querySelector('.system-md-regular') + expect(textContainer).toBeInTheDocument() + expect(textContainer?.textContent).toBe(' ') + }) + }) + + // ================================ + // Accessibility Tests + // ================================ + describe('Accessibility', () => { + it('should have text content visible', () => { + render() + + const textElement = screen.getByText('No plugins available') + expect(textElement).toBeVisible() + }) + + it('should render text in proper container', () => { + const { container } = render() + + const textContainer = container.querySelector('.system-md-regular') + expect(textContainer).toBeInTheDocument() + expect(textContainer).toHaveTextContent('Test message') + }) + + it('should center text content', () => { + const { container } = render() + + const textContainer = container.querySelector('.text-center') + expect(textContainer).toBeInTheDocument() + }) + }) + + // ================================ + // Overlay Tests + // ================================ + describe('Overlay', () => { + it('should render overlay with correct z-index', () => { + const { container } = render() + + const overlay = container.querySelector('.z-\\[1\\]') + expect(overlay).toBeInTheDocument() + }) + + it('should render overlay with full coverage', () => { + const { container } = render() + + const overlay = container.querySelector('.inset-0') + expect(overlay).toBeInTheDocument() + }) + + it('should not render overlay when lightCard is true', () => { + const { container } = render() + + const overlay = container.querySelector('.inset-0.z-\\[1\\]') + expect(overlay).not.toBeInTheDocument() + }) + }) +}) + +// ================================ +// Integration Tests +// ================================ +describe('Empty and Line Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = 'light' + }) + + it('should render Line components with correct theme in Empty', () => { + const { container } = render() + + // In light mode, should use light gradient ID + const lightGradients = container.querySelectorAll('#paint0_linear_1989_74474') + expect(lightGradients.length).toBe(4) + }) + + it('should render Line components with dark theme in Empty', () => { + mockTheme = 'dark' + const { container } = render() + + // In dark mode, should use dark gradient ID + const darkGradients = container.querySelectorAll('#paint0_linear_6295_52176') + expect(darkGradients.length).toBe(4) + }) + + it('should apply positioning classes to Line components', () => { + const { container } = render() + + // Check for Line positioning classes + expect(container.querySelector('.right-\\[-1px\\]')).toBeInTheDocument() + expect(container.querySelector('.left-\\[-1px\\]')).toBeInTheDocument() + expect(container.querySelectorAll('.rotate-90').length).toBe(2) + }) + + it('should render complete Empty component structure', () => { + const { container } = render() + + // Container + expect(container.querySelector('.test')).toBeInTheDocument() + + // Placeholder cards + expect(container.querySelectorAll('.h-\\[144px\\]').length).toBe(16) + + // Icon container + expect(container.querySelector('.h-14.w-14')).toBeInTheDocument() + + // Line components (4) + Group icon (1) = 5 SVGs total + expect(container.querySelectorAll('svg').length).toBe(5) + + // Text + expect(screen.getByText('Test')).toBeInTheDocument() + + // No overlay for lightCard + expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/index.spec.tsx new file mode 100644 index 0000000000..862e55cc67 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/index.spec.tsx @@ -0,0 +1,1700 @@ +import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' +import type { Plugin } from '@/app/components/plugins/types' +import type { Locale } from '@/i18n-config' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import List from './index' +import ListWithCollection from './list-with-collection' +import ListWrapper from './list-wrapper' + +// ================================ +// Mock External Dependencies Only +// ================================ + +// Mock useMixedTranslation hook +vi.mock('../hooks', () => ({ + useMixedTranslation: (_locale?: string) => ({ + t: (key: string, params?: Record) => { + const translations: Record = { + 'plugin.marketplace.viewMore': 'View More', + 'plugin.marketplace.pluginsResult': `${params?.num || 0} plugins found`, + 'plugin.marketplace.noPluginFound': 'No plugins found', + 'plugin.detailPanel.operation.install': 'Install', + 'plugin.detailPanel.operation.detail': 'Detail', + } + return translations[key] || key + }, + }), +})) + +// Mock useMarketplaceContext with controllable values +const mockContextValues = { + plugins: undefined as Plugin[] | undefined, + pluginsTotal: 0, + marketplaceCollectionsFromClient: undefined as MarketplaceCollection[] | undefined, + marketplaceCollectionPluginsMapFromClient: undefined as Record | undefined, + isLoading: false, + isSuccessCollections: false, + handleQueryPlugins: vi.fn(), + searchPluginText: '', + filterPluginTags: [] as string[], + page: 1, + handleMoreClick: vi.fn(), +} + +vi.mock('../context', () => ({ + useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), +})) + +// Mock useI18N context +vi.mock('@/context/i18n', () => ({ + useI18N: () => ({ + locale: 'en-US', + }), +})) + +// Mock next-themes +vi.mock('next-themes', () => ({ + useTheme: () => ({ + theme: 'light', + }), +})) + +// Mock useTags hook +const mockTags = [ + { name: 'search', label: 'Search' }, + { name: 'image', label: 'Image' }, +] + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + tags: mockTags, + tagsMap: mockTags.reduce((acc, tag) => { + acc[tag.name] = tag + return acc + }, {} as Record), + getTagLabel: (name: string) => { + const tag = mockTags.find(t => t.name === name) + return tag?.label || name + }, + }), +})) + +// Mock ahooks useBoolean with controllable state +let mockUseBooleanValue = false +const mockSetTrue = vi.fn(() => { + mockUseBooleanValue = true +}) +const mockSetFalse = vi.fn(() => { + mockUseBooleanValue = false +}) + +vi.mock('ahooks', () => ({ + useBoolean: (_defaultValue: boolean) => { + return [ + mockUseBooleanValue, + { + setTrue: mockSetTrue, + setFalse: mockSetFalse, + toggle: vi.fn(), + }, + ] + }, +})) + +// Mock i18n-config/language +vi.mock('@/i18n-config/language', () => ({ + getLanguage: (locale: string) => locale || 'en-US', +})) + +// Mock marketplace utils +vi.mock('../utils', () => ({ + getPluginLinkInMarketplace: (plugin: Plugin, _params?: Record) => + `/plugins/${plugin.org}/${plugin.name}`, + getPluginDetailLinkInMarketplace: (plugin: Plugin) => + `/plugins/${plugin.org}/${plugin.name}`, +})) + +// Mock Card component +vi.mock('@/app/components/plugins/card', () => ({ + default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => ( +
+
{payload.name}
+
{payload.label?.['en-US'] || payload.name}
+ {footer &&
{footer}
} +
+ ), +})) + +// Mock CardMoreInfo component +vi.mock('@/app/components/plugins/card/card-more-info', () => ({ + default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => ( +
+ {downloadCount} + {tags.join(',')} +
+ ), +})) + +// Mock InstallFromMarketplace component +vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +// Mock SortDropdown component +vi.mock('../sort-dropdown', () => ({ + default: ({ locale }: { locale: Locale }) => ( +
Sort
+ ), +})) + +// Mock Empty component +vi.mock('../empty', () => ({ + default: ({ className, locale }: { className?: string, locale?: string }) => ( +
+ No plugins found +
+ ), +})) + +// Mock Loading component +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
Loading...
, +})) + +// ================================ +// Test Data Factories +// ================================ + +const createMockPlugin = (overrides?: Partial): Plugin => ({ + type: 'plugin', + org: 'test-org', + name: `test-plugin-${Math.random().toString(36).substring(7)}`, + plugin_id: `plugin-${Math.random().toString(36).substring(7)}`, + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'test-org/test-plugin:1.0.0', + icon: '/icon.png', + verified: true, + label: { 'en-US': 'Test Plugin' }, + brief: { 'en-US': 'Test plugin brief description' }, + description: { 'en-US': 'Test plugin full description' }, + introduction: 'Test plugin introduction', + repository: 'https://github.com/test/plugin', + category: PluginCategoryEnum.tool, + install_count: 1000, + endpoint: { settings: [] }, + tags: [{ name: 'search' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', + ...overrides, +}) + +const createMockPluginList = (count: number): Plugin[] => + Array.from({ length: count }, (_, i) => + createMockPlugin({ + name: `plugin-${i}`, + plugin_id: `plugin-id-${i}`, + label: { 'en-US': `Plugin ${i}` }, + })) + +const createMockCollection = (overrides?: Partial): MarketplaceCollection => ({ + name: `collection-${Math.random().toString(36).substring(7)}`, + label: { 'en-US': 'Test Collection' }, + description: { 'en-US': 'Test collection description' }, + rule: 'test-rule', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + searchable: true, + search_params: { query: 'test' }, + ...overrides, +}) + +const createMockCollectionList = (count: number): MarketplaceCollection[] => + Array.from({ length: count }, (_, i) => + createMockCollection({ + name: `collection-${i}`, + label: { 'en-US': `Collection ${i}` }, + description: { 'en-US': `Description for collection ${i}` }, + })) + +// ================================ +// List Component Tests +// ================================ +describe('List', () => { + const defaultProps = { + marketplaceCollections: [] as MarketplaceCollection[], + marketplaceCollectionPluginsMap: {} as Record, + plugins: undefined, + showInstallButton: false, + locale: 'en-US' as Locale, + cardContainerClassName: '', + cardRender: undefined, + onMoreClick: undefined, + emptyClassName: '', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + // Component should render without errors + expect(document.body).toBeInTheDocument() + }) + + it('should render ListWithCollection when plugins prop is undefined', () => { + const collections = createMockCollectionList(2) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(2), + 'collection-1': createMockPluginList(3), + } + + render( + , + ) + + // Should render collection titles + expect(screen.getByText('Collection 0')).toBeInTheDocument() + expect(screen.getByText('Collection 1')).toBeInTheDocument() + }) + + it('should render plugin cards when plugins array is provided', () => { + const plugins = createMockPluginList(3) + + render( + , + ) + + // Should render plugin cards + expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument() + expect(screen.getByTestId('card-plugin-1')).toBeInTheDocument() + expect(screen.getByTestId('card-plugin-2')).toBeInTheDocument() + }) + + it('should render Empty component when plugins array is empty', () => { + render( + , + ) + + expect(screen.getByTestId('empty-component')).toBeInTheDocument() + }) + + it('should not render ListWithCollection when plugins is defined', () => { + const collections = createMockCollectionList(2) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(2), + } + + render( + , + ) + + // Should not render collection titles + expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() + }) + }) + + // ================================ + // Props Testing + // ================================ + describe('Props', () => { + it('should apply cardContainerClassName to grid container', () => { + const plugins = createMockPluginList(2) + const { container } = render( + , + ) + + expect(container.querySelector('.custom-grid-class')).toBeInTheDocument() + }) + + it('should apply emptyClassName to Empty component', () => { + render( + , + ) + + expect(screen.getByTestId('empty-component')).toHaveClass('custom-empty-class') + }) + + it('should pass locale to Empty component', () => { + render( + , + ) + + expect(screen.getByTestId('empty-component')).toHaveAttribute('data-locale', 'zh-CN') + }) + + it('should pass showInstallButton to CardWrapper', () => { + const plugins = createMockPluginList(1) + + const { container } = render( + , + ) + + // CardWrapper should be rendered (via Card mock) + expect(container.querySelector('[data-testid="card-plugin-0"]')).toBeInTheDocument() + }) + }) + + // ================================ + // Custom Card Render Tests + // ================================ + describe('Custom Card Render', () => { + it('should use cardRender function when provided', () => { + const plugins = createMockPluginList(2) + const customCardRender = (plugin: Plugin) => ( +
+ Custom: + {' '} + {plugin.name} +
+ ) + + render( + , + ) + + expect(screen.getByTestId('custom-card-plugin-0')).toBeInTheDocument() + expect(screen.getByTestId('custom-card-plugin-1')).toBeInTheDocument() + expect(screen.getByText('Custom: plugin-0')).toBeInTheDocument() + }) + + it('should handle cardRender returning null', () => { + const plugins = createMockPluginList(2) + const customCardRender = (plugin: Plugin) => { + if (plugin.name === 'plugin-0') + return null + return ( +
+ {plugin.name} +
+ ) + } + + render( + , + ) + + expect(screen.queryByTestId('custom-card-plugin-0')).not.toBeInTheDocument() + expect(screen.getByTestId('custom-card-plugin-1')).toBeInTheDocument() + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty marketplaceCollections', () => { + render( + , + ) + + // Should not throw and render nothing + expect(document.body).toBeInTheDocument() + }) + + it('should handle undefined plugins correctly', () => { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + + render( + , + ) + + // Should render ListWithCollection + expect(screen.getByText('Collection 0')).toBeInTheDocument() + }) + + it('should handle large number of plugins', () => { + const plugins = createMockPluginList(100) + + const { container } = render( + , + ) + + // Should render all plugin cards + const cards = container.querySelectorAll('[data-testid^="card-plugin-"]') + expect(cards.length).toBe(100) + }) + + it('should handle plugins with special characters in name', () => { + const specialPlugin = createMockPlugin({ + name: 'plugin-with-special-chars!@#', + org: 'test-org', + }) + + render( + , + ) + + expect(screen.getByTestId('card-plugin-with-special-chars!@#')).toBeInTheDocument() + }) + }) +}) + +// ================================ +// ListWithCollection Component Tests +// ================================ +describe('ListWithCollection', () => { + const defaultProps = { + marketplaceCollections: [] as MarketplaceCollection[], + marketplaceCollectionPluginsMap: {} as Record, + showInstallButton: false, + locale: 'en-US' as Locale, + cardContainerClassName: '', + cardRender: undefined, + onMoreClick: undefined, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(document.body).toBeInTheDocument() + }) + + it('should render collection labels and descriptions', () => { + const collections = createMockCollectionList(2) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + 'collection-1': createMockPluginList(1), + } + + render( + , + ) + + expect(screen.getByText('Collection 0')).toBeInTheDocument() + expect(screen.getByText('Description for collection 0')).toBeInTheDocument() + expect(screen.getByText('Collection 1')).toBeInTheDocument() + expect(screen.getByText('Description for collection 1')).toBeInTheDocument() + }) + + it('should render plugin cards within collections', () => { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(3), + } + + render( + , + ) + + expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument() + expect(screen.getByTestId('card-plugin-1')).toBeInTheDocument() + expect(screen.getByTestId('card-plugin-2')).toBeInTheDocument() + }) + + it('should not render collections with no plugins', () => { + const collections = createMockCollectionList(2) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + 'collection-1': [], // Empty plugins + } + + render( + , + ) + + expect(screen.getByText('Collection 0')).toBeInTheDocument() + expect(screen.queryByText('Collection 1')).not.toBeInTheDocument() + }) + }) + + // ================================ + // View More Button Tests + // ================================ + describe('View More Button', () => { + it('should render View More button when collection is searchable and onMoreClick is provided', () => { + const collections = [createMockCollection({ + name: 'collection-0', + searchable: true, + search_params: { query: 'test' }, + })] + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + const onMoreClick = vi.fn() + + render( + , + ) + + expect(screen.getByText('View More')).toBeInTheDocument() + }) + + it('should not render View More button when collection is not searchable', () => { + const collections = [createMockCollection({ + name: 'collection-0', + searchable: false, + })] + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + const onMoreClick = vi.fn() + + render( + , + ) + + expect(screen.queryByText('View More')).not.toBeInTheDocument() + }) + + it('should not render View More button when onMoreClick is not provided', () => { + const collections = [createMockCollection({ + name: 'collection-0', + searchable: true, + })] + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + + render( + , + ) + + expect(screen.queryByText('View More')).not.toBeInTheDocument() + }) + + it('should call onMoreClick with search_params when View More is clicked', () => { + const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' } + const collections = [createMockCollection({ + name: 'collection-0', + searchable: true, + search_params: searchParams, + })] + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + const onMoreClick = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText('View More')) + + expect(onMoreClick).toHaveBeenCalledTimes(1) + expect(onMoreClick).toHaveBeenCalledWith(searchParams) + }) + }) + + // ================================ + // Custom Card Render Tests + // ================================ + describe('Custom Card Render', () => { + it('should use cardRender function when provided', () => { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(2), + } + const customCardRender = (plugin: Plugin) => ( +
+ Custom: + {' '} + {plugin.name} +
+ ) + + render( + , + ) + + expect(screen.getByTestId('custom-plugin-0')).toBeInTheDocument() + expect(screen.getByText('Custom: plugin-0')).toBeInTheDocument() + }) + }) + + // ================================ + // Props Testing + // ================================ + describe('Props', () => { + it('should apply cardContainerClassName to grid', () => { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + + const { container } = render( + , + ) + + expect(container.querySelector('.custom-container')).toBeInTheDocument() + }) + + it('should pass showInstallButton to CardWrapper', () => { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + + const { container } = render( + , + ) + + // CardWrapper should be rendered + expect(container.querySelector('[data-testid="card-plugin-0"]')).toBeInTheDocument() + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty collections array', () => { + render( + , + ) + + expect(document.body).toBeInTheDocument() + }) + + it('should handle missing plugins in map', () => { + const collections = createMockCollectionList(1) + // pluginsMap doesn't have the collection + const pluginsMap: Record = {} + + render( + , + ) + + // Collection should not be rendered because it has no plugins + expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() + }) + + it('should handle undefined plugins in map', () => { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': undefined as unknown as Plugin[], + } + + render( + , + ) + + // Collection should not be rendered + expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() + }) + }) +}) + +// ================================ +// ListWrapper Component Tests +// ================================ +describe('ListWrapper', () => { + const defaultProps = { + marketplaceCollections: [] as MarketplaceCollection[], + marketplaceCollectionPluginsMap: {} as Record, + showInstallButton: false, + locale: 'en-US' as Locale, + } + + beforeEach(() => { + vi.clearAllMocks() + // Reset context values + mockContextValues.plugins = undefined + mockContextValues.pluginsTotal = 0 + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined + mockContextValues.isLoading = false + mockContextValues.isSuccessCollections = false + mockContextValues.searchPluginText = '' + mockContextValues.filterPluginTags = [] + mockContextValues.page = 1 + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(document.body).toBeInTheDocument() + }) + + it('should render with scrollbarGutter style', () => { + const { container } = render() + + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveStyle({ scrollbarGutter: 'stable' }) + }) + + it('should render Loading component when isLoading is true and page is 1', () => { + mockContextValues.isLoading = true + mockContextValues.page = 1 + + render() + + expect(screen.getByTestId('loading-component')).toBeInTheDocument() + }) + + it('should not render Loading component when page > 1', () => { + mockContextValues.isLoading = true + mockContextValues.page = 2 + + render() + + expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() + }) + }) + + // ================================ + // Plugins Header Tests + // ================================ + describe('Plugins Header', () => { + it('should render plugins result count when plugins are present', () => { + mockContextValues.plugins = createMockPluginList(5) + mockContextValues.pluginsTotal = 5 + + render() + + expect(screen.getByText('5 plugins found')).toBeInTheDocument() + }) + + it('should render SortDropdown when plugins are present', () => { + mockContextValues.plugins = createMockPluginList(1) + + render() + + expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument() + }) + + it('should not render plugins header when plugins is undefined', () => { + mockContextValues.plugins = undefined + + render() + + expect(screen.queryByTestId('sort-dropdown')).not.toBeInTheDocument() + }) + + it('should pass locale to SortDropdown', () => { + mockContextValues.plugins = createMockPluginList(1) + + render() + + expect(screen.getByTestId('sort-dropdown')).toHaveAttribute('data-locale', 'zh-CN') + }) + }) + + // ================================ + // List Rendering Logic Tests + // ================================ + describe('List Rendering Logic', () => { + it('should render List when not loading', () => { + mockContextValues.isLoading = false + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + + render( + , + ) + + expect(screen.getByText('Collection 0')).toBeInTheDocument() + }) + + it('should render List when loading but page > 1', () => { + mockContextValues.isLoading = true + mockContextValues.page = 2 + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + + render( + , + ) + + expect(screen.getByText('Collection 0')).toBeInTheDocument() + }) + + it('should use client collections when available', () => { + const serverCollections = createMockCollectionList(1) + serverCollections[0].label = { 'en-US': 'Server Collection' } + const clientCollections = createMockCollectionList(1) + clientCollections[0].label = { 'en-US': 'Client Collection' } + + const serverPluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + const clientPluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + + mockContextValues.marketplaceCollectionsFromClient = clientCollections + mockContextValues.marketplaceCollectionPluginsMapFromClient = clientPluginsMap + + render( + , + ) + + expect(screen.getByText('Client Collection')).toBeInTheDocument() + expect(screen.queryByText('Server Collection')).not.toBeInTheDocument() + }) + + it('should use server collections when client collections are not available', () => { + const serverCollections = createMockCollectionList(1) + serverCollections[0].label = { 'en-US': 'Server Collection' } + const serverPluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined + + render( + , + ) + + expect(screen.getByText('Server Collection')).toBeInTheDocument() + }) + }) + + // ================================ + // Context Integration Tests + // ================================ + describe('Context Integration', () => { + it('should pass plugins from context to List', () => { + const plugins = createMockPluginList(2) + mockContextValues.plugins = plugins + + render() + + expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument() + expect(screen.getByTestId('card-plugin-1')).toBeInTheDocument() + }) + + it('should pass handleMoreClick from context to List', () => { + const mockHandleMoreClick = vi.fn() + mockContextValues.handleMoreClick = mockHandleMoreClick + + const collections = [createMockCollection({ + name: 'collection-0', + searchable: true, + search_params: { query: 'test' }, + })] + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + + render( + , + ) + + fireEvent.click(screen.getByText('View More')) + + expect(mockHandleMoreClick).toHaveBeenCalled() + }) + }) + + // ================================ + // Effect Tests (handleQueryPlugins) + // ================================ + describe('handleQueryPlugins Effect', () => { + it('should call handleQueryPlugins when conditions are met', async () => { + const mockHandleQueryPlugins = vi.fn() + mockContextValues.handleQueryPlugins = mockHandleQueryPlugins + mockContextValues.isSuccessCollections = true + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.searchPluginText = '' + mockContextValues.filterPluginTags = [] + + render() + + await waitFor(() => { + expect(mockHandleQueryPlugins).toHaveBeenCalled() + }) + }) + + it('should not call handleQueryPlugins when client collections exist', async () => { + const mockHandleQueryPlugins = vi.fn() + mockContextValues.handleQueryPlugins = mockHandleQueryPlugins + mockContextValues.isSuccessCollections = true + mockContextValues.marketplaceCollectionsFromClient = createMockCollectionList(1) + mockContextValues.searchPluginText = '' + mockContextValues.filterPluginTags = [] + + render() + + // Give time for effect to run + await waitFor(() => { + expect(mockHandleQueryPlugins).not.toHaveBeenCalled() + }) + }) + + it('should not call handleQueryPlugins when search text exists', async () => { + const mockHandleQueryPlugins = vi.fn() + mockContextValues.handleQueryPlugins = mockHandleQueryPlugins + mockContextValues.isSuccessCollections = true + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.searchPluginText = 'search text' + mockContextValues.filterPluginTags = [] + + render() + + await waitFor(() => { + expect(mockHandleQueryPlugins).not.toHaveBeenCalled() + }) + }) + + it('should not call handleQueryPlugins when filter tags exist', async () => { + const mockHandleQueryPlugins = vi.fn() + mockContextValues.handleQueryPlugins = mockHandleQueryPlugins + mockContextValues.isSuccessCollections = true + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.searchPluginText = '' + mockContextValues.filterPluginTags = ['tag1'] + + render() + + await waitFor(() => { + expect(mockHandleQueryPlugins).not.toHaveBeenCalled() + }) + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty plugins array from context', () => { + mockContextValues.plugins = [] + mockContextValues.pluginsTotal = 0 + + render() + + expect(screen.getByText('0 plugins found')).toBeInTheDocument() + expect(screen.getByTestId('empty-component')).toBeInTheDocument() + }) + + it('should handle large pluginsTotal', () => { + mockContextValues.plugins = createMockPluginList(10) + mockContextValues.pluginsTotal = 10000 + + render() + + expect(screen.getByText('10000 plugins found')).toBeInTheDocument() + }) + + it('should handle both loading and has plugins', () => { + mockContextValues.isLoading = true + mockContextValues.page = 2 + mockContextValues.plugins = createMockPluginList(5) + mockContextValues.pluginsTotal = 50 + + render() + + // Should show plugins header and list + expect(screen.getByText('50 plugins found')).toBeInTheDocument() + // Should not show loading because page > 1 + expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() + }) + }) +}) + +// ================================ +// CardWrapper Component Tests (via List integration) +// ================================ +describe('CardWrapper (via List integration)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseBooleanValue = false + }) + + describe('Card Rendering', () => { + it('should render Card with plugin data', () => { + const plugin = createMockPlugin({ + name: 'test-plugin', + label: { 'en-US': 'Test Plugin Label' }, + }) + + render( + , + ) + + expect(screen.getByTestId('card-test-plugin')).toBeInTheDocument() + }) + + it('should render CardMoreInfo with download count and tags', () => { + const plugin = createMockPlugin({ + name: 'test-plugin', + install_count: 5000, + tags: [{ name: 'search' }, { name: 'image' }], + }) + + render( + , + ) + + expect(screen.getByTestId('card-more-info')).toBeInTheDocument() + expect(screen.getByTestId('download-count')).toHaveTextContent('5000') + }) + }) + + describe('Plugin Key Generation', () => { + it('should use org/name as key for plugins', () => { + const plugins = [ + createMockPlugin({ org: 'org1', name: 'plugin1' }), + createMockPlugin({ org: 'org2', name: 'plugin2' }), + ] + + render( + , + ) + + expect(screen.getByTestId('card-plugin1')).toBeInTheDocument() + expect(screen.getByTestId('card-plugin2')).toBeInTheDocument() + }) + }) + + // ================================ + // showInstallButton Branch Tests + // ================================ + describe('showInstallButton=true branch', () => { + it('should render install and detail buttons when showInstallButton is true', () => { + const plugin = createMockPlugin({ name: 'install-test-plugin' }) + + render( + , + ) + + // Should render the card + expect(screen.getByTestId('card-install-test-plugin')).toBeInTheDocument() + // Should render install button + expect(screen.getByText('Install')).toBeInTheDocument() + // Should render detail button + expect(screen.getByText('Detail')).toBeInTheDocument() + }) + + it('should call showInstallFromMarketplace when install button is clicked', () => { + const plugin = createMockPlugin({ name: 'click-test-plugin' }) + + render( + , + ) + + const installButton = screen.getByText('Install') + fireEvent.click(installButton) + + expect(mockSetTrue).toHaveBeenCalled() + }) + + it('should render detail link with correct href', () => { + const plugin = createMockPlugin({ + name: 'link-test-plugin', + org: 'test-org', + }) + + render( + , + ) + + const detailLink = screen.getByText('Detail').closest('a') + expect(detailLink).toHaveAttribute('href', '/plugins/test-org/link-test-plugin') + expect(detailLink).toHaveAttribute('target', '_blank') + }) + + it('should render InstallFromMarketplace modal when isShowInstallFromMarketplace is true', () => { + mockUseBooleanValue = true + const plugin = createMockPlugin({ name: 'modal-test-plugin' }) + + render( + , + ) + + expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument() + }) + + it('should not render InstallFromMarketplace modal when isShowInstallFromMarketplace is false', () => { + mockUseBooleanValue = false + const plugin = createMockPlugin({ name: 'no-modal-test-plugin' }) + + render( + , + ) + + expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument() + }) + + it('should call hideInstallFromMarketplace when modal close is triggered', () => { + mockUseBooleanValue = true + const plugin = createMockPlugin({ name: 'close-modal-plugin' }) + + render( + , + ) + + const closeButton = screen.getByTestId('close-install-modal') + fireEvent.click(closeButton) + + expect(mockSetFalse).toHaveBeenCalled() + }) + }) + + // ================================ + // showInstallButton=false Branch Tests + // ================================ + describe('showInstallButton=false branch', () => { + it('should render as a link when showInstallButton is false', () => { + const plugin = createMockPlugin({ + name: 'link-plugin', + org: 'test-org', + }) + + render( + , + ) + + // Should not render install/detail buttons + expect(screen.queryByText('Install')).not.toBeInTheDocument() + expect(screen.queryByText('Detail')).not.toBeInTheDocument() + }) + + it('should render card within link for non-install mode', () => { + const plugin = createMockPlugin({ + name: 'card-link-plugin', + org: 'card-org', + }) + + render( + , + ) + + expect(screen.getByTestId('card-card-link-plugin')).toBeInTheDocument() + }) + + it('should render with undefined showInstallButton (default false)', () => { + const plugin = createMockPlugin({ name: 'default-plugin' }) + + render( + , + ) + + // Should not render install button (default behavior) + expect(screen.queryByText('Install')).not.toBeInTheDocument() + }) + }) + + // ================================ + // Tag Labels Memoization Tests + // ================================ + describe('Tag Labels', () => { + it('should render tag labels correctly', () => { + const plugin = createMockPlugin({ + name: 'tag-plugin', + tags: [{ name: 'search' }, { name: 'image' }], + }) + + render( + , + ) + + expect(screen.getByTestId('tags')).toHaveTextContent('Search,Image') + }) + + it('should handle empty tags array', () => { + const plugin = createMockPlugin({ + name: 'no-tags-plugin', + tags: [], + }) + + render( + , + ) + + expect(screen.getByTestId('tags')).toHaveTextContent('') + }) + + it('should handle unknown tag names', () => { + const plugin = createMockPlugin({ + name: 'unknown-tag-plugin', + tags: [{ name: 'unknown-tag' }], + }) + + render( + , + ) + + // Unknown tags should show the original name + expect(screen.getByTestId('tags')).toHaveTextContent('unknown-tag') + }) + }) +}) + +// ================================ +// Combined Workflow Tests +// ================================ +describe('Combined Workflows', () => { + beforeEach(() => { + vi.clearAllMocks() + mockContextValues.plugins = undefined + mockContextValues.pluginsTotal = 0 + mockContextValues.isLoading = false + mockContextValues.page = 1 + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined + }) + + it('should transition from loading to showing collections', async () => { + mockContextValues.isLoading = true + mockContextValues.page = 1 + + const { rerender } = render( + , + ) + + expect(screen.getByTestId('loading-component')).toBeInTheDocument() + + // Simulate loading complete + mockContextValues.isLoading = false + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + mockContextValues.marketplaceCollectionsFromClient = collections + mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap + + rerender( + , + ) + + expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() + expect(screen.getByText('Collection 0')).toBeInTheDocument() + }) + + it('should transition from collections to search results', async () => { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + mockContextValues.marketplaceCollectionsFromClient = collections + mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap + + const { rerender } = render( + , + ) + + expect(screen.getByText('Collection 0')).toBeInTheDocument() + + // Simulate search results + mockContextValues.plugins = createMockPluginList(5) + mockContextValues.pluginsTotal = 5 + + rerender( + , + ) + + expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() + expect(screen.getByText('5 plugins found')).toBeInTheDocument() + }) + + it('should handle empty search results', () => { + mockContextValues.plugins = [] + mockContextValues.pluginsTotal = 0 + + render( + , + ) + + expect(screen.getByTestId('empty-component')).toBeInTheDocument() + expect(screen.getByText('0 plugins found')).toBeInTheDocument() + }) + + it('should support pagination (page > 1)', () => { + mockContextValues.plugins = createMockPluginList(40) + mockContextValues.pluginsTotal = 80 + mockContextValues.isLoading = true + mockContextValues.page = 2 + + render( + , + ) + + // Should show existing results while loading more + expect(screen.getByText('80 plugins found')).toBeInTheDocument() + // Should not show loading spinner for pagination + expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() + }) +}) + +// ================================ +// Accessibility Tests +// ================================ +describe('Accessibility', () => { + beforeEach(() => { + vi.clearAllMocks() + mockContextValues.plugins = undefined + mockContextValues.isLoading = false + mockContextValues.page = 1 + }) + + it('should have semantic structure with collections', () => { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + + const { container } = render( + , + ) + + // Should have proper heading structure + const headings = container.querySelectorAll('.title-xl-semi-bold') + expect(headings.length).toBeGreaterThan(0) + }) + + it('should have clickable View More button', () => { + const collections = [createMockCollection({ + name: 'collection-0', + searchable: true, + })] + const pluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + const onMoreClick = vi.fn() + + render( + , + ) + + const viewMoreButton = screen.getByText('View More') + expect(viewMoreButton).toBeInTheDocument() + expect(viewMoreButton.closest('div')).toHaveClass('cursor-pointer') + }) + + it('should have proper grid layout for cards', () => { + const plugins = createMockPluginList(4) + + const { container } = render( + , + ) + + const grid = container.querySelector('.grid-cols-4') + expect(grid).toBeInTheDocument() + }) +}) + +// ================================ +// Performance Tests +// ================================ +describe('Performance', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should handle rendering many plugins efficiently', () => { + const plugins = createMockPluginList(50) + + const startTime = performance.now() + render( + , + ) + const endTime = performance.now() + + // Should render in reasonable time (less than 1 second) + expect(endTime - startTime).toBeLessThan(1000) + }) + + it('should handle rendering many collections efficiently', () => { + const collections = createMockCollectionList(10) + const pluginsMap: Record = {} + collections.forEach((collection) => { + pluginsMap[collection.name] = createMockPluginList(5) + }) + + const startTime = performance.now() + render( + , + ) + const endTime = performance.now() + + // Should render in reasonable time (less than 1 second) + expect(endTime - startTime).toBeLessThan(1000) + }) +}) diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/index.spec.tsx new file mode 100644 index 0000000000..66feeb3fe1 --- /dev/null +++ b/web/app/components/plugins/marketplace/search-box/index.spec.tsx @@ -0,0 +1,1289 @@ +import type { Tag } from '@/app/components/plugins/hooks' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SearchBox from './index' +import SearchBoxWrapper from './search-box-wrapper' +import MarketplaceTrigger from './trigger/marketplace' +import ToolSelectorTrigger from './trigger/tool-selector' + +// ================================ +// Mock external dependencies only +// ================================ + +// Mock useMixedTranslation hook +vi.mock('../hooks', () => ({ + useMixedTranslation: (_locale?: string) => ({ + t: (key: string) => { + const translations: Record = { + 'pluginTags.allTags': 'All Tags', + 'pluginTags.searchTags': 'Search tags', + 'plugin.searchPlugins': 'Search plugins', + } + return translations[key] || key + }, + }), +})) + +// Mock useMarketplaceContext +const mockContextValues = { + searchPluginText: '', + handleSearchPluginTextChange: vi.fn(), + filterPluginTags: [] as string[], + handleFilterPluginTagsChange: vi.fn(), +} + +vi.mock('../context', () => ({ + useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), +})) + +// Mock useTags hook +const mockTags: Tag[] = [ + { name: 'agent', label: 'Agent' }, + { name: 'rag', label: 'RAG' }, + { name: 'search', label: 'Search' }, + { name: 'image', label: 'Image' }, + { name: 'videos', label: 'Videos' }, +] + +const mockTagsMap: Record = mockTags.reduce((acc, tag) => { + acc[tag.name] = tag + return acc +}, {} as Record) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + tags: mockTags, + tagsMap: mockTagsMap, + }), +})) + +// Mock portal-to-follow-elem with shared open state +let mockPortalOpenState = false + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { + children: React.ReactNode + open: boolean + }) => { + mockPortalOpenState = open + return ( +
+ {children} +
+ ) + }, + PortalToFollowElemTrigger: ({ children, onClick, className }: { + children: React.ReactNode + onClick: () => void + className?: string + }) => ( +
+ {children} +
+ ), + PortalToFollowElemContent: ({ children, className }: { + children: React.ReactNode + className?: string + }) => { + // Only render content when portal is open + if (!mockPortalOpenState) + return null + return ( +
+ {children} +
+ ) + }, +})) + +// ================================ +// SearchBox Component Tests +// ================================ +describe('SearchBox', () => { + const defaultProps = { + search: '', + onSearchChange: vi.fn(), + tags: [] as string[], + onTagsChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should render with marketplace mode styling', () => { + const { container } = render( + , + ) + + // In marketplace mode, TagsFilter comes before input + expect(container.querySelector('.rounded-xl')).toBeInTheDocument() + }) + + it('should render with non-marketplace mode styling', () => { + const { container } = render( + , + ) + + // In non-marketplace mode, search icon appears first + expect(container.querySelector('.radius-md')).toBeInTheDocument() + }) + + it('should render placeholder correctly', () => { + render() + + expect(screen.getByPlaceholderText('Search here...')).toBeInTheDocument() + }) + + it('should render search input with current value', () => { + render() + + expect(screen.getByDisplayValue('test query')).toBeInTheDocument() + }) + + it('should render TagsFilter component', () => { + render() + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + // ================================ + // Marketplace Mode Tests + // ================================ + describe('Marketplace Mode', () => { + it('should render TagsFilter before input in marketplace mode', () => { + render() + + const portalElem = screen.getByTestId('portal-elem') + const input = screen.getByRole('textbox') + + // Both should be rendered + expect(portalElem).toBeInTheDocument() + expect(input).toBeInTheDocument() + }) + + it('should render clear button when search has value in marketplace mode', () => { + render() + + // ActionButton with close icon should be rendered + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('should not render clear button when search is empty in marketplace mode', () => { + const { container } = render() + + // RiCloseLine icon should not be visible (it's within ActionButton) + const closeIcons = container.querySelectorAll('.size-4') + // Only filter icons should be present, not close button + expect(closeIcons.length).toBeLessThan(3) + }) + }) + + // ================================ + // Non-Marketplace Mode Tests + // ================================ + describe('Non-Marketplace Mode', () => { + it('should render search icon at the beginning', () => { + const { container } = render( + , + ) + + // Search icon should be present + expect(container.querySelector('.text-components-input-text-placeholder')).toBeInTheDocument() + }) + + it('should render clear button when search has value', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('should render TagsFilter after input in non-marketplace mode', () => { + render() + + const portalElem = screen.getByTestId('portal-elem') + const input = screen.getByRole('textbox') + + expect(portalElem).toBeInTheDocument() + expect(input).toBeInTheDocument() + }) + + it('should set autoFocus when prop is true', () => { + render() + + const input = screen.getByRole('textbox') + // autoFocus is a boolean attribute that React handles specially + expect(input).toBeInTheDocument() + }) + }) + + // ================================ + // User Interactions Tests + // ================================ + describe('User Interactions', () => { + it('should call onSearchChange when input value changes', () => { + const onSearchChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'new search' } }) + + expect(onSearchChange).toHaveBeenCalledWith('new search') + }) + + it('should call onSearchChange with empty string when clear button is clicked in marketplace mode', () => { + const onSearchChange = vi.fn() + render( + , + ) + + const buttons = screen.getAllByRole('button') + // Find the clear button (the one in the search area) + const clearButton = buttons[buttons.length - 1] + fireEvent.click(clearButton) + + expect(onSearchChange).toHaveBeenCalledWith('') + }) + + it('should call onSearchChange with empty string when clear button is clicked in non-marketplace mode', () => { + const onSearchChange = vi.fn() + render( + , + ) + + const buttons = screen.getAllByRole('button') + // First button should be the clear button in non-marketplace mode + fireEvent.click(buttons[0]) + + expect(onSearchChange).toHaveBeenCalledWith('') + }) + + it('should handle rapid typing correctly', () => { + const onSearchChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + + fireEvent.change(input, { target: { value: 'a' } }) + fireEvent.change(input, { target: { value: 'ab' } }) + fireEvent.change(input, { target: { value: 'abc' } }) + + expect(onSearchChange).toHaveBeenCalledTimes(3) + expect(onSearchChange).toHaveBeenLastCalledWith('abc') + }) + }) + + // ================================ + // Add Custom Tool Button Tests + // ================================ + describe('Add Custom Tool Button', () => { + it('should render add custom tool button when supportAddCustomTool is true', () => { + render() + + // The add button should be rendered + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should not render add custom tool button when supportAddCustomTool is false', () => { + const { container } = render( + , + ) + + // Check for the rounded-full button which is the add button + const addButton = container.querySelector('.rounded-full') + expect(addButton).not.toBeInTheDocument() + }) + + it('should call onShowAddCustomCollectionModal when add button is clicked', () => { + const onShowAddCustomCollectionModal = vi.fn() + render( + , + ) + + // Find the add button (it has rounded-full class) + const buttons = screen.getAllByRole('button') + const addButton = buttons.find(btn => + btn.className.includes('rounded-full'), + ) + + if (addButton) { + fireEvent.click(addButton) + expect(onShowAddCustomCollectionModal).toHaveBeenCalledTimes(1) + } + }) + }) + + // ================================ + // Props Variations Tests + // ================================ + describe('Props Variations', () => { + it('should apply wrapperClassName correctly', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.custom-wrapper-class')).toBeInTheDocument() + }) + + it('should apply inputClassName correctly', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.custom-input-class')).toBeInTheDocument() + }) + + it('should pass locale to TagsFilter', () => { + render() + + // TagsFilter should be rendered with locale + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle empty placeholder', () => { + render() + + expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', '') + }) + + it('should use default placeholder when not provided', () => { + render() + + expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', '') + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle empty search value', () => { + render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toHaveValue('') + }) + + it('should handle empty tags array', () => { + render() + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle special characters in search', () => { + const onSearchChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '' } }) + + expect(onSearchChange).toHaveBeenCalledWith('') + }) + + it('should handle very long search strings', () => { + const longString = 'a'.repeat(1000) + render() + + expect(screen.getByDisplayValue(longString)).toBeInTheDocument() + }) + + it('should handle whitespace-only search', () => { + const onSearchChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: ' ' } }) + + expect(onSearchChange).toHaveBeenCalledWith(' ') + }) + }) +}) + +// ================================ +// SearchBoxWrapper Component Tests +// ================================ +describe('SearchBoxWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + // Reset context values + mockContextValues.searchPluginText = '' + mockContextValues.filterPluginTags = [] + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should render with locale prop', () => { + render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should render in marketplace mode', () => { + const { container } = render() + + expect(container.querySelector('.rounded-xl')).toBeInTheDocument() + }) + + it('should apply correct wrapper classes', () => { + const { container } = render() + + // Check for z-[11] class from wrapper + expect(container.querySelector('.z-\\[11\\]')).toBeInTheDocument() + }) + }) + + describe('Context Integration', () => { + it('should use searchPluginText from context', () => { + mockContextValues.searchPluginText = 'context search' + render() + + expect(screen.getByDisplayValue('context search')).toBeInTheDocument() + }) + + it('should call handleSearchPluginTextChange when search changes', () => { + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'new search' } }) + + expect(mockContextValues.handleSearchPluginTextChange).toHaveBeenCalledWith('new search') + }) + + it('should use filterPluginTags from context', () => { + mockContextValues.filterPluginTags = ['agent', 'rag'] + render() + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + }) + + describe('Translation', () => { + it('should use translation for placeholder', () => { + render() + + expect(screen.getByPlaceholderText('Search plugins')).toBeInTheDocument() + }) + + it('should pass locale to useMixedTranslation', () => { + render() + + // Translation should still work + expect(screen.getByPlaceholderText('Search plugins')).toBeInTheDocument() + }) + }) +}) + +// ================================ +// MarketplaceTrigger Component Tests +// ================================ +describe('MarketplaceTrigger', () => { + const defaultProps = { + selectedTagsLength: 0, + open: false, + tags: [] as string[], + tagsMap: mockTagsMap, + onTagsChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(screen.getByText('All Tags')).toBeInTheDocument() + }) + + it('should show "All Tags" when no tags selected', () => { + render() + + expect(screen.getByText('All Tags')).toBeInTheDocument() + }) + + it('should show arrow down icon when no tags selected', () => { + const { container } = render( + , + ) + + // Arrow down icon should be present + expect(container.querySelector('.size-4')).toBeInTheDocument() + }) + }) + + describe('Selected Tags Display', () => { + it('should show selected tag labels when tags are selected', () => { + render( + , + ) + + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + + it('should show multiple tag labels separated by comma', () => { + render( + , + ) + + expect(screen.getByText('Agent,RAG')).toBeInTheDocument() + }) + + it('should show +N indicator when more than 2 tags selected', () => { + render( + , + ) + + expect(screen.getByText('+2')).toBeInTheDocument() + }) + + it('should only show first 2 tags in label', () => { + render( + , + ) + + expect(screen.getByText('Agent,RAG')).toBeInTheDocument() + expect(screen.queryByText('Search')).not.toBeInTheDocument() + }) + }) + + describe('Clear Tags Button', () => { + it('should show clear button when tags are selected', () => { + const { container } = render( + , + ) + + // RiCloseCircleFill icon should be present + expect(container.querySelector('.text-text-quaternary')).toBeInTheDocument() + }) + + it('should not show clear button when no tags selected', () => { + const { container } = render( + , + ) + + // Clear button should not be present + expect(container.querySelector('.text-text-quaternary')).not.toBeInTheDocument() + }) + + it('should call onTagsChange with empty array when clear is clicked', () => { + const onTagsChange = vi.fn() + const { container } = render( + , + ) + + const clearButton = container.querySelector('.text-text-quaternary') + if (clearButton) { + fireEvent.click(clearButton) + expect(onTagsChange).toHaveBeenCalledWith([]) + } + }) + }) + + describe('Open State Styling', () => { + it('should apply hover styling when open and no tags selected', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.bg-state-base-hover')).toBeInTheDocument() + }) + + it('should apply border styling when tags are selected', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.border-components-button-secondary-border')).toBeInTheDocument() + }) + }) + + describe('Props Variations', () => { + it('should handle locale prop', () => { + render() + + expect(screen.getByText('All Tags')).toBeInTheDocument() + }) + + it('should handle empty tagsMap', () => { + const { container } = render( + , + ) + + expect(container).toBeInTheDocument() + }) + }) +}) + +// ================================ +// ToolSelectorTrigger Component Tests +// ================================ +describe('ToolSelectorTrigger', () => { + const defaultProps = { + selectedTagsLength: 0, + open: false, + tags: [] as string[], + tagsMap: mockTagsMap, + onTagsChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + + expect(container).toBeInTheDocument() + }) + + it('should render price tag icon', () => { + const { container } = render() + + expect(container.querySelector('.size-4')).toBeInTheDocument() + }) + }) + + describe('Selected Tags Display', () => { + it('should show selected tag labels when tags are selected', () => { + render( + , + ) + + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + + it('should show multiple tag labels separated by comma', () => { + render( + , + ) + + expect(screen.getByText('Agent,RAG')).toBeInTheDocument() + }) + + it('should show +N indicator when more than 2 tags selected', () => { + render( + , + ) + + expect(screen.getByText('+2')).toBeInTheDocument() + }) + + it('should not show tag labels when no tags selected', () => { + render() + + expect(screen.queryByText('Agent')).not.toBeInTheDocument() + }) + }) + + describe('Clear Tags Button', () => { + it('should show clear button when tags are selected', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.text-text-quaternary')).toBeInTheDocument() + }) + + it('should not show clear button when no tags selected', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.text-text-quaternary')).not.toBeInTheDocument() + }) + + it('should call onTagsChange with empty array when clear is clicked', () => { + const onTagsChange = vi.fn() + const { container } = render( + , + ) + + const clearButton = container.querySelector('.text-text-quaternary') + if (clearButton) { + fireEvent.click(clearButton) + expect(onTagsChange).toHaveBeenCalledWith([]) + } + }) + + it('should stop propagation when clear button is clicked', () => { + const onTagsChange = vi.fn() + const parentClickHandler = vi.fn() + + const { container } = render( +
+ +
, + ) + + const clearButton = container.querySelector('.text-text-quaternary') + if (clearButton) { + fireEvent.click(clearButton) + expect(onTagsChange).toHaveBeenCalledWith([]) + // Parent should not be called due to stopPropagation + expect(parentClickHandler).not.toHaveBeenCalled() + } + }) + }) + + describe('Open State Styling', () => { + it('should apply hover styling when open and no tags selected', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.bg-state-base-hover')).toBeInTheDocument() + }) + + it('should apply border styling when tags are selected', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.border-components-button-secondary-border')).toBeInTheDocument() + }) + + it('should not apply hover styling when open but has tags', () => { + const { container } = render( + , + ) + + // Should have border styling, not hover + expect(container.querySelector('.border-components-button-secondary-border')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render with single tag correctly', () => { + render( + , + ) + + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + }) +}) + +// ================================ +// TagsFilter Component Tests (Integration) +// ================================ +describe('TagsFilter', () => { + // We need to import TagsFilter separately for these tests + // since it uses the mocked portal components + + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + }) + + describe('Integration with SearchBox', () => { + it('should render TagsFilter within SearchBox', () => { + render( + , + ) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should pass usedInMarketplace prop to TagsFilter', () => { + render( + , + ) + + // MarketplaceTrigger should show "All Tags" + expect(screen.getByText('All Tags')).toBeInTheDocument() + }) + + it('should show selected tags count in TagsFilter trigger', () => { + render( + , + ) + + expect(screen.getByText('+1')).toBeInTheDocument() + }) + }) + + describe('Dropdown Behavior', () => { + it('should open dropdown when trigger is clicked', async () => { + render( + , + ) + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + await waitFor(() => { + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) + + it('should close dropdown when trigger is clicked again', async () => { + render( + , + ) + + const trigger = screen.getByTestId('portal-trigger') + + // Open + fireEvent.click(trigger) + await waitFor(() => { + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + // Close + fireEvent.click(trigger) + await waitFor(() => { + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + }) + }) + + describe('Tag Selection', () => { + it('should display tag options when dropdown is open', async () => { + render( + , + ) + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + expect(screen.getByText('RAG')).toBeInTheDocument() + }) + }) + + it('should call onTagsChange when a tag is selected', async () => { + const onTagsChange = vi.fn() + render( + , + ) + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + + const agentOption = screen.getByText('Agent') + fireEvent.click(agentOption.parentElement!) + expect(onTagsChange).toHaveBeenCalledWith(['agent']) + }) + + it('should call onTagsChange to remove tag when already selected', async () => { + const onTagsChange = vi.fn() + render( + , + ) + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + await waitFor(() => { + // Multiple 'Agent' texts exist - one in trigger, one in dropdown + expect(screen.getAllByText('Agent').length).toBeGreaterThanOrEqual(1) + }) + + // Get the portal content and find the tag option within it + const portalContent = screen.getByTestId('portal-content') + const agentOption = portalContent.querySelector('div[class*="cursor-pointer"]') + if (agentOption) { + fireEvent.click(agentOption) + expect(onTagsChange).toHaveBeenCalled() + } + }) + + it('should add to existing tags when selecting new tag', async () => { + const onTagsChange = vi.fn() + render( + , + ) + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + await waitFor(() => { + expect(screen.getByText('RAG')).toBeInTheDocument() + }) + + const ragOption = screen.getByText('RAG') + fireEvent.click(ragOption.parentElement!) + expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag']) + }) + }) + + describe('Search Tags Feature', () => { + it('should render search input in dropdown', async () => { + render( + , + ) + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + await waitFor(() => { + const inputs = screen.getAllByRole('textbox') + expect(inputs.length).toBeGreaterThanOrEqual(1) + }) + }) + + it('should filter tags based on search text', async () => { + render( + , + ) + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + + const inputs = screen.getAllByRole('textbox') + const searchInput = inputs.find(input => + input.getAttribute('placeholder') === 'Search tags', + ) + + if (searchInput) { + fireEvent.change(searchInput, { target: { value: 'agent' } }) + expect(screen.getByText('Agent')).toBeInTheDocument() + } + }) + }) + + describe('Checkbox State', () => { + // Note: The Checkbox component is a custom div-based component, not native checkbox + it('should display tag options with proper selection state', async () => { + render( + , + ) + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + await waitFor(() => { + // 'Agent' appears both in trigger (selected) and dropdown + expect(screen.getAllByText('Agent').length).toBeGreaterThanOrEqual(1) + }) + + // Verify dropdown content is rendered + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should render tag options when dropdown is open', async () => { + render( + , + ) + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + await waitFor(() => { + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + // When no tags selected, these should appear once each in dropdown + expect(screen.getByText('Agent')).toBeInTheDocument() + expect(screen.getByText('RAG')).toBeInTheDocument() + expect(screen.getByText('Search')).toBeInTheDocument() + }) + }) +}) + +// ================================ +// Accessibility Tests +// ================================ +describe('Accessibility', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + }) + + it('should have accessible search input', () => { + render( + , + ) + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('placeholder', 'Search plugins') + }) + + it('should have clickable tag options in dropdown', async () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + }) +}) + +// ================================ +// Combined Workflow Tests +// ================================ +describe('Combined Workflows', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + }) + + it('should handle search and tag filter together', async () => { + const onSearchChange = vi.fn() + const onTagsChange = vi.fn() + + render( + , + ) + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'search query' } }) + expect(onSearchChange).toHaveBeenCalledWith('search query') + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + + const agentOption = screen.getByText('Agent') + fireEvent.click(agentOption.parentElement!) + expect(onTagsChange).toHaveBeenCalledWith(['agent']) + }) + + it('should work with all features enabled', () => { + render( + , + ) + + expect(screen.getByDisplayValue('test')).toBeInTheDocument() + expect(screen.getByText('Agent,RAG')).toBeInTheDocument() + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + + it('should handle prop changes correctly', () => { + const onSearchChange = vi.fn() + + const { rerender } = render( + , + ) + + expect(screen.getByDisplayValue('initial')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByDisplayValue('updated')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx new file mode 100644 index 0000000000..35858268d8 --- /dev/null +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx @@ -0,0 +1,734 @@ +import type { MarketplaceContextValue } from '../context' +import { fireEvent, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SortDropdown from './index' + +// ================================ +// Mock external dependencies only +// ================================ + +// Mock useMixedTranslation hook +const mockTranslation = vi.fn((key: string) => { + const translations: Record = { + 'plugin.marketplace.sortBy': 'Sort by', + 'plugin.marketplace.sortOption.mostPopular': 'Most Popular', + 'plugin.marketplace.sortOption.recentlyUpdated': 'Recently Updated', + 'plugin.marketplace.sortOption.newlyReleased': 'Newly Released', + 'plugin.marketplace.sortOption.firstReleased': 'First Released', + } + return translations[key] || key +}) + +vi.mock('../hooks', () => ({ + useMixedTranslation: (_locale?: string) => ({ + t: mockTranslation, + }), +})) + +// Mock marketplace context with controllable values +let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } +const mockHandleSortChange = vi.fn() + +vi.mock('../context', () => ({ + useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => { + const contextValue = { + sort: mockSort, + handleSortChange: mockHandleSortChange, + } as unknown as MarketplaceContextValue + return selector(contextValue) + }, +})) + +// Mock portal component with controllable open state +let mockPortalOpenState = false + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open, onOpenChange }: { + children: React.ReactNode + open: boolean + onOpenChange: (open: boolean) => void + }) => { + mockPortalOpenState = open + return ( +
+ {children} +
+ ) + }, + PortalToFollowElemTrigger: ({ children, onClick }: { + children: React.ReactNode + onClick: () => void + }) => ( +
+ {children} +
+ ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { + // Match actual behavior: only render when portal is open + if (!mockPortalOpenState) + return null + return
{children}
+ }, +})) + +// ================================ +// Test Factory Functions +// ================================ + +type SortOption = { + value: string + order: string + text: string +} + +const createSortOptions = (): SortOption[] => [ + { value: 'install_count', order: 'DESC', text: 'Most Popular' }, + { value: 'version_updated_at', order: 'DESC', text: 'Recently Updated' }, + { value: 'created_at', order: 'DESC', text: 'Newly Released' }, + { value: 'created_at', order: 'ASC', text: 'First Released' }, +] + +// ================================ +// SortDropdown Component Tests +// ================================ +describe('SortDropdown', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } + mockPortalOpenState = false + }) + + // ================================ + // Rendering Tests + // ================================ + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + }) + + it('should render sort by label', () => { + render() + + expect(screen.getByText('Sort by')).toBeInTheDocument() + }) + + it('should render selected option text', () => { + render() + + expect(screen.getByText('Most Popular')).toBeInTheDocument() + }) + + it('should render arrow down icon', () => { + const { container } = render() + + const arrowIcon = container.querySelector('.h-4.w-4.text-text-tertiary') + expect(arrowIcon).toBeInTheDocument() + }) + + it('should render trigger element with correct styles', () => { + const { container } = render() + + const trigger = container.querySelector('.cursor-pointer') + expect(trigger).toBeInTheDocument() + expect(trigger).toHaveClass('h-8', 'rounded-lg', 'bg-state-base-hover-alt') + }) + + it('should not render dropdown content when closed', () => { + render() + + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + }) + + // ================================ + // Props Testing + // ================================ + describe('Props', () => { + it('should accept locale prop', () => { + render() + + expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + }) + + it('should call useMixedTranslation with provided locale', () => { + render() + + // Translation function should be called for labels + expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortBy') + }) + + it('should render without locale prop (undefined)', () => { + render() + + expect(screen.getByText('Sort by')).toBeInTheDocument() + }) + + it('should render with empty string locale', () => { + render() + + expect(screen.getByText('Sort by')).toBeInTheDocument() + }) + }) + + // ================================ + // State Management Tests + // ================================ + describe('State Management', () => { + it('should initialize with closed state', () => { + render() + + const wrapper = screen.getByTestId('portal-wrapper') + expect(wrapper).toHaveAttribute('data-open', 'false') + }) + + it('should display correct selected option for install_count DESC', () => { + mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } + render() + + expect(screen.getByText('Most Popular')).toBeInTheDocument() + }) + + it('should display correct selected option for version_updated_at DESC', () => { + mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' } + render() + + expect(screen.getByText('Recently Updated')).toBeInTheDocument() + }) + + it('should display correct selected option for created_at DESC', () => { + mockSort = { sortBy: 'created_at', sortOrder: 'DESC' } + render() + + expect(screen.getByText('Newly Released')).toBeInTheDocument() + }) + + it('should display correct selected option for created_at ASC', () => { + mockSort = { sortBy: 'created_at', sortOrder: 'ASC' } + render() + + expect(screen.getByText('First Released')).toBeInTheDocument() + }) + + it('should toggle open state when trigger clicked', () => { + render() + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // After click, portal content should be visible + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should close dropdown when trigger clicked again', () => { + render() + + const trigger = screen.getByTestId('portal-trigger') + + // Open + fireEvent.click(trigger) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Close + fireEvent.click(trigger) + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + }) + + // ================================ + // User Interactions Tests + // ================================ + describe('User Interactions', () => { + it('should open dropdown on trigger click', () => { + render() + + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should render all sort options when open', () => { + render() + + // Open dropdown + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + expect(within(content).getByText('Most Popular')).toBeInTheDocument() + expect(within(content).getByText('Recently Updated')).toBeInTheDocument() + expect(within(content).getByText('Newly Released')).toBeInTheDocument() + expect(within(content).getByText('First Released')).toBeInTheDocument() + }) + + it('should call handleSortChange when option clicked', () => { + render() + + // Open dropdown + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Click on "Recently Updated" + const content = screen.getByTestId('portal-content') + fireEvent.click(within(content).getByText('Recently Updated')) + + expect(mockHandleSortChange).toHaveBeenCalledWith({ + sortBy: 'version_updated_at', + sortOrder: 'DESC', + }) + }) + + it('should call handleSortChange with correct params for Most Popular', () => { + mockSort = { sortBy: 'created_at', sortOrder: 'DESC' } + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + fireEvent.click(within(content).getByText('Most Popular')) + + expect(mockHandleSortChange).toHaveBeenCalledWith({ + sortBy: 'install_count', + sortOrder: 'DESC', + }) + }) + + it('should call handleSortChange with correct params for Newly Released', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + fireEvent.click(within(content).getByText('Newly Released')) + + expect(mockHandleSortChange).toHaveBeenCalledWith({ + sortBy: 'created_at', + sortOrder: 'DESC', + }) + }) + + it('should call handleSortChange with correct params for First Released', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + fireEvent.click(within(content).getByText('First Released')) + + expect(mockHandleSortChange).toHaveBeenCalledWith({ + sortBy: 'created_at', + sortOrder: 'ASC', + }) + }) + + it('should allow selecting currently selected option', () => { + mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + fireEvent.click(within(content).getByText('Most Popular')) + + expect(mockHandleSortChange).toHaveBeenCalledWith({ + sortBy: 'install_count', + sortOrder: 'DESC', + }) + }) + + it('should support userEvent for trigger click', async () => { + const user = userEvent.setup() + render() + + const trigger = screen.getByTestId('portal-trigger') + await user.click(trigger) + + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) + + // ================================ + // Check Icon Tests + // ================================ + describe('Check Icon', () => { + it('should show check icon for selected option', () => { + mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } + const { container } = render() + + // Open dropdown + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Check icon should be present in the dropdown + const checkIcon = container.querySelector('.text-text-accent') + expect(checkIcon).toBeInTheDocument() + }) + + it('should show check icon only for matching sortBy AND sortOrder', () => { + mockSort = { sortBy: 'created_at', sortOrder: 'DESC' } + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + const options = content.querySelectorAll('.cursor-pointer') + + // "Newly Released" (created_at DESC) should have check icon + // "First Released" (created_at ASC) should NOT have check icon + expect(options.length).toBe(4) + }) + + it('should not show check icon for different sortOrder with same sortBy', () => { + mockSort = { sortBy: 'created_at', sortOrder: 'DESC' } + const { container } = render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Only one check icon should be visible (for Newly Released, not First Released) + const checkIcons = container.querySelectorAll('.text-text-accent') + expect(checkIcons.length).toBe(1) + }) + }) + + // ================================ + // Dropdown Options Structure Tests + // ================================ + describe('Dropdown Options Structure', () => { + const sortOptions = createSortOptions() + + it('should render 4 sort options', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + const options = content.querySelectorAll('.cursor-pointer') + expect(options.length).toBe(4) + }) + + it.each(sortOptions)('should render option: $text', ({ text }) => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + expect(within(content).getByText(text)).toBeInTheDocument() + }) + + it('should render options with unique keys', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + const options = content.querySelectorAll('.cursor-pointer') + + // All options should be rendered (no key conflicts) + expect(options.length).toBe(4) + }) + + it('should render dropdown container with correct styles', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + const container = content.firstChild as HTMLElement + expect(container).toHaveClass('rounded-xl', 'shadow-lg') + }) + + it('should render option items with hover styles', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + const option = content.querySelector('.cursor-pointer') + expect(option).toHaveClass('hover:bg-components-panel-on-panel-item-bg-hover') + }) + }) + + // ================================ + // Edge Cases Tests + // ================================ + describe('Edge Cases', () => { + it('should handle unknown sortBy value gracefully', () => { + mockSort = { sortBy: 'unknown_field', sortOrder: 'DESC' } + + // This may cause an error or undefined behavior + // Component uses find() which returns undefined for non-matching + expect(() => render()).toThrow() + }) + + it('should handle empty sortBy value', () => { + mockSort = { sortBy: '', sortOrder: 'DESC' } + + expect(() => render()).toThrow() + }) + + it('should handle unknown sortOrder value', () => { + mockSort = { sortBy: 'install_count', sortOrder: 'UNKNOWN' } + + // No matching option, selectedOption will be undefined + expect(() => render()).toThrow() + }) + + it('should render correctly when handleSortChange is a no-op', () => { + mockHandleSortChange.mockImplementation(() => {}) + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + fireEvent.click(within(content).getByText('Recently Updated')) + + expect(mockHandleSortChange).toHaveBeenCalled() + }) + + it('should handle rapid toggle clicks', () => { + render() + + const trigger = screen.getByTestId('portal-trigger') + + // Rapid clicks + fireEvent.click(trigger) + fireEvent.click(trigger) + fireEvent.click(trigger) + + // Final state should be open (odd number of clicks) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should handle multiple option selections', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + + // Click multiple options + fireEvent.click(within(content).getByText('Recently Updated')) + fireEvent.click(within(content).getByText('Newly Released')) + fireEvent.click(within(content).getByText('First Released')) + + expect(mockHandleSortChange).toHaveBeenCalledTimes(3) + }) + }) + + // ================================ + // Context Integration Tests + // ================================ + describe('Context Integration', () => { + it('should read sort value from context', () => { + mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' } + render() + + expect(screen.getByText('Recently Updated')).toBeInTheDocument() + }) + + it('should call context handleSortChange on selection', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + fireEvent.click(within(content).getByText('First Released')) + + expect(mockHandleSortChange).toHaveBeenCalledWith({ + sortBy: 'created_at', + sortOrder: 'ASC', + }) + }) + + it('should update display when context sort changes', () => { + const { rerender } = render() + + expect(screen.getByText('Most Popular')).toBeInTheDocument() + + // Simulate context change + mockSort = { sortBy: 'created_at', sortOrder: 'ASC' } + rerender() + + expect(screen.getByText('First Released')).toBeInTheDocument() + }) + + it('should use selector pattern correctly', () => { + render() + + // Component should have called useMarketplaceContext with selector functions + expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + }) + }) + + // ================================ + // Accessibility Tests + // ================================ + describe('Accessibility', () => { + it('should have cursor pointer on trigger', () => { + const { container } = render() + + const trigger = container.querySelector('.cursor-pointer') + expect(trigger).toBeInTheDocument() + }) + + it('should have cursor pointer on options', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + const options = content.querySelectorAll('.cursor-pointer') + expect(options.length).toBeGreaterThan(0) + }) + + it('should have visible focus indicators via hover styles', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + const option = content.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover') + expect(option).toBeInTheDocument() + }) + }) + + // ================================ + // Translation Tests + // ================================ + describe('Translations', () => { + it('should call translation for sortBy label', () => { + render() + + expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortBy') + }) + + it('should call translation for all sort options', () => { + render() + + expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortOption.mostPopular') + expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortOption.recentlyUpdated') + expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortOption.newlyReleased') + expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortOption.firstReleased') + }) + + it('should pass locale to useMixedTranslation', () => { + render() + + // Verify component renders with locale + expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument() + }) + }) + + // ================================ + // Portal Component Integration Tests + // ================================ + describe('Portal Component Integration', () => { + it('should pass open state to PortalToFollowElem', () => { + render() + + const wrapper = screen.getByTestId('portal-wrapper') + expect(wrapper).toHaveAttribute('data-open', 'false') + + fireEvent.click(screen.getByTestId('portal-trigger')) + + expect(wrapper).toHaveAttribute('data-open', 'true') + }) + + it('should render trigger content inside PortalToFollowElemTrigger', () => { + render() + + const trigger = screen.getByTestId('portal-trigger') + expect(within(trigger).getByText('Sort by')).toBeInTheDocument() + expect(within(trigger).getByText('Most Popular')).toBeInTheDocument() + }) + + it('should render options inside PortalToFollowElemContent', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + expect(within(content).getByText('Most Popular')).toBeInTheDocument() + }) + }) + + // ================================ + // Visual Style Tests + // ================================ + describe('Visual Styles', () => { + it('should apply correct trigger container styles', () => { + const { container } = render() + + const triggerDiv = container.querySelector('.flex.h-8.cursor-pointer.items-center.rounded-lg') + expect(triggerDiv).toBeInTheDocument() + }) + + it('should apply secondary text color to sort by label', () => { + const { container } = render() + + const label = container.querySelector('.text-text-secondary') + expect(label).toBeInTheDocument() + expect(label?.textContent).toBe('Sort by') + }) + + it('should apply primary text color to selected option', () => { + const { container } = render() + + const selected = container.querySelector('.text-text-primary.system-sm-medium') + expect(selected).toBeInTheDocument() + }) + + it('should apply tertiary text color to arrow icon', () => { + const { container } = render() + + const arrow = container.querySelector('.text-text-tertiary') + expect(arrow).toBeInTheDocument() + }) + + it('should apply accent text color to check icon when option selected', () => { + mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } + const { container } = render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const checkIcon = container.querySelector('.text-text-accent') + expect(checkIcon).toBeInTheDocument() + }) + + it('should apply blur backdrop to dropdown container', () => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + const container = content.querySelector('.backdrop-blur-sm') + expect(container).toBeInTheDocument() + }) + }) + + // ================================ + // All Sort Options Click Tests + // ================================ + describe('All Sort Options Click Handlers', () => { + const testCases = [ + { text: 'Most Popular', sortBy: 'install_count', sortOrder: 'DESC' }, + { text: 'Recently Updated', sortBy: 'version_updated_at', sortOrder: 'DESC' }, + { text: 'Newly Released', sortBy: 'created_at', sortOrder: 'DESC' }, + { text: 'First Released', sortBy: 'created_at', sortOrder: 'ASC' }, + ] + + it.each(testCases)( + 'should call handleSortChange with { sortBy: "$sortBy", sortOrder: "$sortOrder" } when clicking "$text"', + ({ text, sortBy, sortOrder }) => { + render() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + const content = screen.getByTestId('portal-content') + fireEvent.click(within(content).getByText(text)) + + expect(mockHandleSortChange).toHaveBeenCalledWith({ sortBy, sortOrder }) + }, + ) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.spec.tsx new file mode 100644 index 0000000000..d9e1bf9cc3 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.spec.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DeleteConfirm } from './delete-confirm' + +const mockRefetch = vi.fn() +const mockDelete = vi.fn() +const mockToast = vi.fn() + +vi.mock('./use-subscription-list', () => ({ + useSubscriptionList: () => ({ refetch: mockRefetch }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: (args: { type: string, message: string }) => mockToast(args), + }, +})) + +beforeEach(() => { + vi.clearAllMocks() + mockDelete.mockImplementation((_id: string, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) +}) + +describe('DeleteConfirm', () => { + it('should prevent deletion when workflows in use and input mismatch', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ })) + + expect(mockDelete).not.toHaveBeenCalled() + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should allow deletion after matching input name', () => { + const onClose = vi.fn() + + render( + , + ) + + fireEvent.change( + screen.getByPlaceholderText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirmInputPlaceholder/), + { target: { value: 'Subscription One' } }, + ) + + fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ })) + + expect(mockDelete).toHaveBeenCalledWith('sub-1', expect.any(Object)) + expect(mockRefetch).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledWith(true) + }) + + it('should show error toast when delete fails', () => { + mockDelete.mockImplementation((_id: string, options?: { onError?: (error: Error) => void }) => { + options?.onError?.(new Error('network error')) + }) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ })) + + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'network error' })) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.spec.tsx new file mode 100644 index 0000000000..e5e82d4c0e --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.spec.tsx @@ -0,0 +1,101 @@ +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { ApiKeyEditModal } from './apikey-edit-modal' + +const mockRefetch = vi.fn() +const mockUpdate = vi.fn() +const mockVerify = vi.fn() +const mockToast = vi.fn() + +vi.mock('../../store', () => ({ + usePluginStore: () => ({ + detail: { + id: 'detail-1', + plugin_id: 'plugin-1', + name: 'Plugin', + plugin_unique_identifier: 'plugin-uid', + provider: 'provider-1', + declaration: { + trigger: { + subscription_constructor: { + parameters: [], + credentials_schema: [ + { + name: 'api_key', + type: 'secret', + label: 'API Key', + required: false, + default: 'token', + }, + ], + }, + }, + }, + }, + }), +})) + +vi.mock('../use-subscription-list', () => ({ + useSubscriptionList: () => ({ refetch: mockRefetch }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useUpdateTriggerSubscription: () => ({ mutate: mockUpdate, isPending: false }), + useVerifyTriggerSubscription: () => ({ mutate: mockVerify, isPending: false }), + useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }), +})) + +vi.mock('@/app/components/base/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: { + notify: (args: { type: string, message: string }) => mockToast(args), + }, + useToastContext: () => ({ + notify: (args: { type: string, message: string }) => mockToast(args), + close: vi.fn(), + }), + } +}) + +const createSubscription = (overrides: Partial = {}): TriggerSubscription => ({ + id: 'sub-1', + name: 'Subscription One', + provider: 'provider-1', + credential_type: TriggerCredentialTypeEnum.ApiKey, + credentials: {}, + endpoint: 'https://example.com', + parameters: {}, + properties: {}, + workflows_in_use: 0, + ...overrides, +}) + +beforeEach(() => { + vi.clearAllMocks() + mockVerify.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + mockUpdate.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) +}) + +describe('ApiKeyEditModal', () => { + it('should render verify step with encrypted hint and allow cancel', () => { + const onClose = vi.fn() + + render() + + expect(screen.getByRole('button', { name: 'pluginTrigger.modal.common.verify' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'pluginTrigger.modal.common.back' })).not.toBeInTheDocument() + expect(screen.getByText(content => content.includes('common.provider.encrypted.front'))).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.spec.tsx new file mode 100644 index 0000000000..048c20eeeb --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.spec.tsx @@ -0,0 +1,98 @@ +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { ManualEditModal } from './manual-edit-modal' + +const mockRefetch = vi.fn() +const mockUpdate = vi.fn() +const mockToast = vi.fn() + +vi.mock('../../store', () => ({ + usePluginStore: () => ({ + detail: { + id: 'detail-1', + plugin_id: 'plugin-1', + name: 'Plugin', + plugin_unique_identifier: 'plugin-uid', + provider: 'provider-1', + declaration: { trigger: { subscription_schema: [] } }, + }, + }), +})) + +vi.mock('../use-subscription-list', () => ({ + useSubscriptionList: () => ({ refetch: mockRefetch }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useUpdateTriggerSubscription: () => ({ mutate: mockUpdate, isPending: false }), + useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }), +})) + +vi.mock('@/app/components/base/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: { + notify: (args: { type: string, message: string }) => mockToast(args), + }, + useToastContext: () => ({ + notify: (args: { type: string, message: string }) => mockToast(args), + close: vi.fn(), + }), + } +}) + +const createSubscription = (overrides: Partial = {}): TriggerSubscription => ({ + id: 'sub-1', + name: 'Subscription One', + provider: 'provider-1', + credential_type: TriggerCredentialTypeEnum.Unauthorized, + credentials: {}, + endpoint: 'https://example.com', + parameters: {}, + properties: {}, + workflows_in_use: 0, + ...overrides, +}) + +beforeEach(() => { + vi.clearAllMocks() + mockUpdate.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) +}) + +describe('ManualEditModal', () => { + it('should render title and allow cancel', () => { + const onClose = vi.fn() + + render() + + expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should submit update with default values', () => { + const onClose = vi.fn() + + render() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptionId: 'sub-1', + name: 'Subscription One', + properties: undefined, + }), + expect.any(Object), + ) + expect(mockRefetch).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.spec.tsx new file mode 100644 index 0000000000..ccbe4792ac --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.spec.tsx @@ -0,0 +1,98 @@ +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { OAuthEditModal } from './oauth-edit-modal' + +const mockRefetch = vi.fn() +const mockUpdate = vi.fn() +const mockToast = vi.fn() + +vi.mock('../../store', () => ({ + usePluginStore: () => ({ + detail: { + id: 'detail-1', + plugin_id: 'plugin-1', + name: 'Plugin', + plugin_unique_identifier: 'plugin-uid', + provider: 'provider-1', + declaration: { trigger: { subscription_constructor: { parameters: [] } } }, + }, + }), +})) + +vi.mock('../use-subscription-list', () => ({ + useSubscriptionList: () => ({ refetch: mockRefetch }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useUpdateTriggerSubscription: () => ({ mutate: mockUpdate, isPending: false }), + useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }), +})) + +vi.mock('@/app/components/base/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: { + notify: (args: { type: string, message: string }) => mockToast(args), + }, + useToastContext: () => ({ + notify: (args: { type: string, message: string }) => mockToast(args), + close: vi.fn(), + }), + } +}) + +const createSubscription = (overrides: Partial = {}): TriggerSubscription => ({ + id: 'sub-1', + name: 'Subscription One', + provider: 'provider-1', + credential_type: TriggerCredentialTypeEnum.Oauth2, + credentials: {}, + endpoint: 'https://example.com', + parameters: {}, + properties: {}, + workflows_in_use: 0, + ...overrides, +}) + +beforeEach(() => { + vi.clearAllMocks() + mockUpdate.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) +}) + +describe('OAuthEditModal', () => { + it('should render title and allow cancel', () => { + const onClose = vi.fn() + + render() + + expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should submit update with default values', () => { + const onClose = vi.fn() + + render() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptionId: 'sub-1', + name: 'Subscription One', + parameters: undefined, + }), + expect.any(Object), + ) + expect(mockRefetch).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.spec.tsx new file mode 100644 index 0000000000..5c71977bc7 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.spec.tsx @@ -0,0 +1,213 @@ +import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types' +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { SubscriptionList } from './index' +import { SubscriptionListMode } from './types' + +const mockRefetch = vi.fn() +let mockSubscriptionListError: Error | null = null +let mockSubscriptionListState: { + isLoading: boolean + refetch: () => void + subscriptions?: TriggerSubscription[] +} + +let mockPluginDetail: PluginDetail | undefined + +vi.mock('./use-subscription-list', () => ({ + useSubscriptionList: () => { + if (mockSubscriptionListError) + throw mockSubscriptionListError + return mockSubscriptionListState + }, +})) + +vi.mock('../../store', () => ({ + usePluginStore: (selector: (state: { detail: PluginDetail | undefined }) => PluginDetail | undefined) => + selector({ detail: mockPluginDetail }), +})) + +const mockInitiateOAuth = vi.fn() +const mockDeleteSubscription = vi.fn() + +vi.mock('@/service/use-triggers', () => ({ + useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }), + useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }), + useInitiateTriggerOAuth: () => ({ mutate: mockInitiateOAuth }), + useDeleteTriggerSubscription: () => ({ mutate: mockDeleteSubscription, isPending: false }), +})) + +const createSubscription = (overrides: Partial = {}): TriggerSubscription => ({ + id: 'sub-1', + name: 'Subscription One', + provider: 'provider-1', + credential_type: TriggerCredentialTypeEnum.ApiKey, + credentials: {}, + endpoint: 'https://example.com', + parameters: {}, + properties: {}, + workflows_in_use: 0, + ...overrides, +}) + +const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({ + id: 'plugin-detail-1', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + name: 'Test Plugin', + plugin_id: 'plugin-id', + plugin_unique_identifier: 'plugin-uid', + declaration: {} as PluginDeclaration, + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'plugin-uid', + source: 'marketplace' as PluginDetail['source'], + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +beforeEach(() => { + vi.clearAllMocks() + mockRefetch.mockReset() + mockSubscriptionListError = null + mockPluginDetail = undefined + mockSubscriptionListState = { + isLoading: false, + refetch: mockRefetch, + subscriptions: [createSubscription()], + } +}) + +describe('SubscriptionList', () => { + describe('Rendering', () => { + it('should render list view by default', () => { + render() + + expect(screen.getByText(/pluginTrigger\.subscription\.listNum/)).toBeInTheDocument() + expect(screen.getByText('Subscription One')).toBeInTheDocument() + }) + + it('should render loading state when subscriptions are loading', () => { + mockSubscriptionListState = { + ...mockSubscriptionListState, + isLoading: true, + } + + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('Subscription One')).not.toBeInTheDocument() + }) + + it('should render list view with plugin detail provided', () => { + const pluginDetail = createPluginDetail() + + render() + + expect(screen.getByText('Subscription One')).toBeInTheDocument() + }) + + it('should render without list entries when subscriptions are empty', () => { + mockSubscriptionListState = { + ...mockSubscriptionListState, + subscriptions: [], + } + + render() + + expect(screen.queryByText(/pluginTrigger\.subscription\.listNum/)).not.toBeInTheDocument() + expect(screen.queryByText('Subscription One')).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should render selector view when mode is selector', () => { + render() + + expect(screen.getByText('Subscription One')).toBeInTheDocument() + }) + + it('should highlight the selected subscription when selectedId is provided', () => { + render( + , + ) + + const selectedButton = screen.getByRole('button', { name: 'Subscription One' }) + const selectedRow = selectedButton.closest('div') + + expect(selectedRow).toHaveClass('bg-state-base-hover') + }) + }) + + describe('User Interactions', () => { + it('should call onSelect with refetch callback when selecting a subscription', () => { + const onSelect = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Subscription One' })) + + expect(onSelect).toHaveBeenCalledTimes(1) + const [selectedSubscription, callback] = onSelect.mock.calls[0] + expect(selectedSubscription).toMatchObject({ id: 'sub-1', name: 'Subscription One' }) + expect(typeof callback).toBe('function') + + callback?.() + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + it('should not throw when onSelect is undefined', () => { + render() + + expect(() => { + fireEvent.click(screen.getByRole('button', { name: 'Subscription One' })) + }).not.toThrow() + }) + + it('should open delete confirm without triggering selection', () => { + const onSelect = vi.fn() + const { container } = render( + , + ) + + const deleteButton = container.querySelector('.subscription-delete-btn') + expect(deleteButton).toBeTruthy() + + if (deleteButton) + fireEvent.click(deleteButton) + + expect(onSelect).not.toHaveBeenCalled() + expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render error boundary fallback when an error occurs', () => { + mockSubscriptionListError = new Error('boom') + + render() + + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.spec.tsx new file mode 100644 index 0000000000..bac4b5f8ff --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.spec.tsx @@ -0,0 +1,63 @@ +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { SubscriptionListView } from './list-view' + +let mockSubscriptions: TriggerSubscription[] = [] + +vi.mock('./use-subscription-list', () => ({ + useSubscriptionList: () => ({ subscriptions: mockSubscriptions }), +})) + +vi.mock('../../store', () => ({ + usePluginStore: () => ({ detail: undefined }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }), + useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }), + useInitiateTriggerOAuth: () => ({ mutate: vi.fn() }), +})) + +const createSubscription = (overrides: Partial = {}): TriggerSubscription => ({ + id: 'sub-1', + name: 'Subscription One', + provider: 'provider-1', + credential_type: TriggerCredentialTypeEnum.ApiKey, + credentials: {}, + endpoint: 'https://example.com', + parameters: {}, + properties: {}, + workflows_in_use: 0, + ...overrides, +}) + +beforeEach(() => { + mockSubscriptions = [] +}) + +describe('SubscriptionListView', () => { + it('should render subscription count and list when data exists', () => { + mockSubscriptions = [createSubscription()] + + render() + + expect(screen.getByText(/pluginTrigger\.subscription\.listNum/)).toBeInTheDocument() + expect(screen.getByText('Subscription One')).toBeInTheDocument() + }) + + it('should omit count and list when subscriptions are empty', () => { + render() + + expect(screen.queryByText(/pluginTrigger\.subscription\.listNum/)).not.toBeInTheDocument() + expect(screen.queryByText('Subscription One')).not.toBeInTheDocument() + }) + + it('should apply top border when showTopBorder is true', () => { + const { container } = render() + + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('border-t') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.spec.tsx new file mode 100644 index 0000000000..44e041d6e2 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer.spec.tsx @@ -0,0 +1,179 @@ +import type { TriggerLogEntity } from '@/app/components/workflow/block-selector/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import LogViewer from './log-viewer' + +const mockToastNotify = vi.fn() +const mockWriteText = vi.fn() + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: (args: { type: string, message: string }) => mockToastNotify(args), + }, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ value }: { value: unknown }) => ( +
{JSON.stringify(value)}
+ ), +})) + +const createLog = (overrides: Partial = {}): TriggerLogEntity => ({ + id: 'log-1', + endpoint: 'https://example.com', + created_at: '2024-01-01T12:34:56Z', + request: { + method: 'POST', + url: 'https://example.com', + headers: { + 'Host': 'example.com', + 'User-Agent': 'vitest', + 'Content-Length': '0', + 'Accept': '*/*', + 'Content-Type': 'application/json', + 'X-Forwarded-For': '127.0.0.1', + 'X-Forwarded-Host': 'example.com', + 'X-Forwarded-Proto': 'https', + 'X-Github-Delivery': '1', + 'X-Github-Event': 'push', + 'X-Github-Hook-Id': '1', + 'X-Github-Hook-Installation-Target-Id': '1', + 'X-Github-Hook-Installation-Target-Type': 'repo', + 'Accept-Encoding': 'gzip', + }, + data: 'payload=%7B%22foo%22%3A%22bar%22%7D', + }, + response: { + status_code: 200, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '2', + }, + data: '{"ok":true}', + }, + ...overrides, +}) + +beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: mockWriteText, + }, + configurable: true, + }) +}) + +describe('LogViewer', () => { + it('should render nothing when logs are empty', () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('should render collapsed log entries', () => { + render() + + expect(screen.getByText(/pluginTrigger\.modal\.manual\.logs\.request/)).toBeInTheDocument() + expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument() + }) + + it('should expand and render request/response payloads', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })) + + const editors = screen.getAllByTestId('code-editor') + expect(editors.length).toBe(2) + expect(editors[0]).toHaveTextContent('"foo":"bar"') + }) + + it('should collapse expanded content when clicked again', () => { + render() + + const trigger = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }) + fireEvent.click(trigger) + expect(screen.getAllByTestId('code-editor').length).toBe(2) + + fireEvent.click(trigger) + expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument() + }) + + it('should render error styling when response is an error', () => { + render() + + const trigger = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }) + const wrapper = trigger.parentElement as HTMLElement + + expect(wrapper).toHaveClass('border-state-destructive-border') + }) + + it('should render raw response text and allow copying', () => { + const rawLog = { + ...createLog(), + response: 'plain response', + } as unknown as TriggerLogEntity + + render() + + const toggleButton = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }) + fireEvent.click(toggleButton) + + expect(screen.getByText('plain response')).toBeInTheDocument() + + const copyButton = screen.getAllByRole('button').find(button => button !== toggleButton) + expect(copyButton).toBeDefined() + if (copyButton) + fireEvent.click(copyButton) + expect(mockWriteText).toHaveBeenCalledWith('plain response') + expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) + }) + + it('should parse request data when it is raw JSON', () => { + const log = createLog({ request: { ...createLog().request, data: '{\"hello\":1}' } }) + + render() + + fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })) + + expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('"hello":1') + }) + + it('should fallback to raw payload when decoding fails', () => { + const log = createLog({ request: { ...createLog().request, data: 'payload=%E0%A4%A' } }) + + render() + + fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })) + + expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('payload=%E0%A4%A') + }) + + it('should keep request data string when JSON parsing fails', () => { + const log = createLog({ request: { ...createLog().request, data: '{invalid}' } }) + + render() + + fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })) + + expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('{invalid}') + }) + + it('should render multiple log entries with distinct indices', () => { + const first = createLog({ id: 'log-1' }) + const second = createLog({ id: 'log-2', created_at: '2024-01-01T12:35:00Z' }) + + render() + + expect(screen.getByText(/#1/)).toBeInTheDocument() + expect(screen.getByText(/#2/)).toBeInTheDocument() + }) + + it('should use index-based key when id is missing', () => { + const log = { ...createLog(), id: '' } + + render() + + expect(screen.getByText(/#1/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.spec.tsx new file mode 100644 index 0000000000..09ea047e40 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.spec.tsx @@ -0,0 +1,91 @@ +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { SubscriptionSelectorEntry } from './selector-entry' + +let mockSubscriptions: TriggerSubscription[] = [] +const mockRefetch = vi.fn() + +vi.mock('./use-subscription-list', () => ({ + useSubscriptionList: () => ({ + subscriptions: mockSubscriptions, + isLoading: false, + refetch: mockRefetch, + }), +})) + +vi.mock('../../store', () => ({ + usePluginStore: () => ({ detail: undefined }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }), + useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }), + useInitiateTriggerOAuth: () => ({ mutate: vi.fn() }), + useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +const createSubscription = (overrides: Partial = {}): TriggerSubscription => ({ + id: 'sub-1', + name: 'Subscription One', + provider: 'provider-1', + credential_type: TriggerCredentialTypeEnum.ApiKey, + credentials: {}, + endpoint: 'https://example.com', + parameters: {}, + properties: {}, + workflows_in_use: 0, + ...overrides, +}) + +beforeEach(() => { + vi.clearAllMocks() + mockSubscriptions = [createSubscription()] +}) + +describe('SubscriptionSelectorEntry', () => { + it('should render empty state label when no selection and closed', () => { + render() + + expect(screen.getByText('pluginTrigger.subscription.noSubscriptionSelected')).toBeInTheDocument() + }) + + it('should render placeholder when open without selection', () => { + render() + + fireEvent.click(screen.getByRole('button')) + + expect(screen.getByText('pluginTrigger.subscription.selectPlaceholder')).toBeInTheDocument() + }) + + it('should show selected subscription name when id matches', () => { + render() + + expect(screen.getByText('Subscription One')).toBeInTheDocument() + }) + + it('should show removed label when selected subscription is missing', () => { + render() + + expect(screen.getByText('pluginTrigger.subscription.subscriptionRemoved')).toBeInTheDocument() + }) + + it('should call onSelect and close the list after selection', () => { + const onSelect = vi.fn() + + render() + + fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getByRole('button', { name: 'Subscription One' })) + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'sub-1', name: 'Subscription One' }), expect.any(Function)) + expect(screen.queryByText('Subscription One')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.spec.tsx new file mode 100644 index 0000000000..eeba994602 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.spec.tsx @@ -0,0 +1,139 @@ +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { SubscriptionSelectorView } from './selector-view' + +let mockSubscriptions: TriggerSubscription[] = [] +const mockRefetch = vi.fn() +const mockDelete = vi.fn((_: string, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() +}) + +vi.mock('./use-subscription-list', () => ({ + useSubscriptionList: () => ({ subscriptions: mockSubscriptions, refetch: mockRefetch }), +})) + +vi.mock('../../store', () => ({ + usePluginStore: () => ({ detail: undefined }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }), + useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }), + useInitiateTriggerOAuth: () => ({ mutate: vi.fn() }), + useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +const createSubscription = (overrides: Partial = {}): TriggerSubscription => ({ + id: 'sub-1', + name: 'Subscription One', + provider: 'provider-1', + credential_type: TriggerCredentialTypeEnum.ApiKey, + credentials: {}, + endpoint: 'https://example.com', + parameters: {}, + properties: {}, + workflows_in_use: 0, + ...overrides, +}) + +beforeEach(() => { + vi.clearAllMocks() + mockSubscriptions = [createSubscription()] +}) + +describe('SubscriptionSelectorView', () => { + it('should render subscription list when data exists', () => { + render() + + expect(screen.getByText(/pluginTrigger\.subscription\.listNum/)).toBeInTheDocument() + expect(screen.getByText('Subscription One')).toBeInTheDocument() + }) + + it('should call onSelect when a subscription is clicked', () => { + const onSelect = vi.fn() + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Subscription One' })) + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'sub-1', name: 'Subscription One' })) + }) + + it('should handle missing onSelect without crashing', () => { + render() + + expect(() => { + fireEvent.click(screen.getByRole('button', { name: 'Subscription One' })) + }).not.toThrow() + }) + + it('should highlight selected subscription row when selectedId matches', () => { + render() + + const selectedRow = screen.getByRole('button', { name: 'Subscription One' }).closest('div') + expect(selectedRow).toHaveClass('bg-state-base-hover') + }) + + it('should not highlight row when selectedId does not match', () => { + render() + + const row = screen.getByRole('button', { name: 'Subscription One' }).closest('div') + expect(row).not.toHaveClass('bg-state-base-hover') + }) + + it('should omit header when there are no subscriptions', () => { + mockSubscriptions = [] + + render() + + expect(screen.queryByText(/pluginTrigger\.subscription\.listNum/)).not.toBeInTheDocument() + }) + + it('should show delete confirm when delete action is clicked', () => { + const { container } = render() + + const deleteButton = container.querySelector('.subscription-delete-btn') + expect(deleteButton).toBeTruthy() + + if (deleteButton) + fireEvent.click(deleteButton) + + expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument() + }) + + it('should request selection reset after confirming delete', () => { + const onSelect = vi.fn() + const { container } = render() + + const deleteButton = container.querySelector('.subscription-delete-btn') + if (deleteButton) + fireEvent.click(deleteButton) + + fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ })) + + expect(mockDelete).toHaveBeenCalledWith('sub-1', expect.any(Object)) + expect(onSelect).toHaveBeenCalledWith({ id: '', name: '' }) + }) + + it('should close delete confirm without selection reset on cancel', () => { + const onSelect = vi.fn() + const { container } = render() + + const deleteButton = container.querySelector('.subscription-delete-btn') + if (deleteButton) + fireEvent.click(deleteButton) + + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/ })) + + expect(onSelect).not.toHaveBeenCalled() + expect(screen.queryByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.spec.tsx new file mode 100644 index 0000000000..e707ab0b01 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.spec.tsx @@ -0,0 +1,91 @@ +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import SubscriptionCard from './subscription-card' + +const mockRefetch = vi.fn() + +vi.mock('./use-subscription-list', () => ({ + useSubscriptionList: () => ({ refetch: mockRefetch }), +})) + +vi.mock('../../store', () => ({ + usePluginStore: () => ({ + detail: { + id: 'detail-1', + plugin_id: 'plugin-1', + name: 'Plugin', + plugin_unique_identifier: 'plugin-uid', + provider: 'provider-1', + declaration: { trigger: { subscription_constructor: { parameters: [], credentials_schema: [] } } }, + }, + }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useUpdateTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }), + useVerifyTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +const createSubscription = (overrides: Partial = {}): TriggerSubscription => ({ + id: 'sub-1', + name: 'Subscription One', + provider: 'provider-1', + credential_type: TriggerCredentialTypeEnum.ApiKey, + credentials: {}, + endpoint: 'https://example.com', + parameters: {}, + properties: {}, + workflows_in_use: 0, + ...overrides, +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('SubscriptionCard', () => { + it('should render subscription name and endpoint', () => { + render() + + expect(screen.getByText('Subscription One')).toBeInTheDocument() + expect(screen.getByText('https://example.com')).toBeInTheDocument() + }) + + it('should render used-by text when workflows are present', () => { + render() + + expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.usedByNum/)).toBeInTheDocument() + }) + + it('should open delete confirmation when delete action is clicked', () => { + const { container } = render() + + const deleteButton = container.querySelector('.subscription-delete-btn') + expect(deleteButton).toBeTruthy() + + if (deleteButton) + fireEvent.click(deleteButton) + + expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument() + }) + + it('should open edit modal when edit action is clicked', () => { + const { container } = render() + + const actionButtons = container.querySelectorAll('button') + const editButton = actionButtons[0] + + fireEvent.click(editButton) + + expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.spec.ts new file mode 100644 index 0000000000..1f462344bf --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.spec.ts @@ -0,0 +1,67 @@ +import type { SimpleDetail } from '../store' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSubscriptionList } from './use-subscription-list' + +let mockDetail: SimpleDetail | undefined +const mockRefetch = vi.fn() + +const mockTriggerSubscriptions = vi.fn() + +vi.mock('@/service/use-triggers', () => ({ + useTriggerSubscriptions: (...args: unknown[]) => mockTriggerSubscriptions(...args), +})) + +vi.mock('../store', () => ({ + usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) => + selector({ detail: mockDetail }), +})) + +beforeEach(() => { + vi.clearAllMocks() + mockDetail = undefined + mockTriggerSubscriptions.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }) +}) + +describe('useSubscriptionList', () => { + it('should request subscriptions with provider from store', () => { + mockDetail = { + id: 'detail-1', + plugin_id: 'plugin-1', + name: 'Plugin', + plugin_unique_identifier: 'plugin-uid', + provider: 'test-provider', + declaration: {}, + } + + const { result } = renderHook(() => useSubscriptionList()) + + expect(mockTriggerSubscriptions).toHaveBeenCalledWith('test-provider') + expect(result.current.detail).toEqual(mockDetail) + }) + + it('should request subscriptions with empty provider when detail is missing', () => { + const { result } = renderHook(() => useSubscriptionList()) + + expect(mockTriggerSubscriptions).toHaveBeenCalledWith('') + expect(result.current.detail).toBeUndefined() + }) + + it('should return data from trigger subscription hook', () => { + mockTriggerSubscriptions.mockReturnValue({ + data: [{ id: 'sub-1' }], + isLoading: true, + refetch: mockRefetch, + }) + + const { result } = renderHook(() => useSubscriptionList()) + + expect(result.current.subscriptions).toEqual([{ id: 'sub-1' }]) + expect(result.current.isLoading).toBe(true) + expect(result.current.refetch).toBe(mockRefetch) + }) +})