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