dify/web/app/components/plugins/install-plugin/install-bundle/index.spec.tsx

1432 lines
48 KiB
TypeScript

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> = {}): 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> = {}): VersionProps => ({
hasInstalled: false,
installedVersion: undefined,
toInstallVersion: '1.0.0',
...overrides,
})
const createMockInstallStatus = (overrides: Partial<InstallStatus> = {}): 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<string, string>,
description: { 'en-US': 'Test package plugin' } as Record<string, string>,
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> = {}): 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<string, string>,
description: { 'en-US': 'Test package plugin' } as Record<string, string>,
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
}) => (
<div data-testid="ready-to-install">
<span data-testid="current-step">{step}</span>
<span data-testid="plugins-count">{allPlugins?.length || 0}</span>
<button data-testid="start-install-btn" onClick={onStartToInstall}>Start Install</button>
<button data-testid="set-installing-true" onClick={() => setIsInstalling(true)}>Set Installing True</button>
<button data-testid="set-installing-false" onClick={() => setIsInstalling(false)}>Set Installing False</button>
<button data-testid="change-to-installed" onClick={() => onStepChange(InstallStep.installed)}>Change to Installed</button>
<button data-testid="change-to-upload-failed" onClick={() => onStepChange(InstallStep.uploadFailed)}>Change to Upload Failed</button>
<button data-testid="change-to-ready" onClick={() => onStepChange(InstallStep.readyToInstall)}>Change to Ready</button>
<button data-testid="close-btn" onClick={onClose}>Close</button>
</div>
),
}))
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(<InstallBundle {...defaultProps} />)
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
})
it('should render ReadyToInstall component', () => {
render(<InstallBundle {...defaultProps} />)
expect(screen.getByTestId('ready-to-install')).toBeInTheDocument()
})
it('should integrate with useHideLogic hook', () => {
render(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} installType={InstallType.fromMarketplace} />)
expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall)
})
it('should set initial step to uploading when installType is fromLocal', () => {
render(<InstallBundle {...defaultProps} installType={InstallType.fromLocal} />)
expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.uploading)
})
it('should set initial step to uploading when installType is fromDSL', () => {
render(<InstallBundle {...defaultProps} installType={InstallType.fromDSL} />)
expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.uploading)
})
})
describe('fromDSLPayload', () => {
it('should pass allPlugins to ReadyToInstall', () => {
const plugins = createMockDependencies()
render(<InstallBundle {...defaultProps} fromDSLPayload={plugins} />)
expect(screen.getByTestId('plugins-count')).toHaveTextContent('3')
})
it('should handle empty fromDSLPayload array', () => {
render(<InstallBundle {...defaultProps} fromDSLPayload={[]} />)
expect(screen.getByTestId('plugins-count')).toHaveTextContent('0')
})
it('should handle single plugin in fromDSLPayload', () => {
render(<InstallBundle {...defaultProps} fromDSLPayload={[createMockDependency()]} />)
expect(screen.getByTestId('plugins-count')).toHaveTextContent('1')
})
})
describe('onClose', () => {
it('should pass onClose to ReadyToInstall', () => {
const onClose = vi.fn()
render(<InstallBundle {...defaultProps} onClose={onClose} />)
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(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} />)
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(<InstallBundle {...defaultProps} />)
expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall)
})
it('should update ReadyToInstall step when onStepChange is called', () => {
render(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} />)
fireEvent.click(screen.getByTestId('start-install-btn'))
expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1)
})
it('should pass setIsInstalling to ReadyToInstall', () => {
render(<InstallBundle {...defaultProps} />)
fireEvent.click(screen.getByTestId('set-installing-true'))
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(true)
})
it('should pass setIsInstalling with false to ReadyToInstall', () => {
render(<InstallBundle {...defaultProps} />)
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(<InstallBundle {...defaultProps} installType={InstallType.fromLocal} />)
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(<InstallBundle {...defaultProps} />)
fireEvent.click(screen.getByTestId('change-to-installed'))
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
})
it('should return installPlugin title for all other steps', () => {
render(<InstallBundle {...defaultProps} />)
// Default step - readyToInstall
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
})
it('should return installPlugin title when step is uploading', () => {
render(<InstallBundle {...defaultProps} installType={InstallType.fromLocal} />)
// 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(
<InstallBundle fromDSLPayload={payload} onClose={onClose} />,
)
// Re-render with same props reference
rerender(<InstallBundle fromDSLPayload={payload} onClose={onClose} />)
// 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(<InstallBundle {...defaultProps} />)
fireEvent.click(screen.getByTestId('start-install-btn'))
expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1)
})
it('should handle close button click', () => {
const onClose = vi.fn()
render(<InstallBundle {...defaultProps} onClose={onClose} />)
fireEvent.click(screen.getByTestId('close-btn'))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should handle step change to installed', () => {
render(<InstallBundle {...defaultProps} />)
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(<InstallBundle {...defaultProps} />)
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(<InstallBundle fromDSLPayload={[]} onClose={vi.fn()} />)
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(<InstallBundle fromDSLPayload={largeDependencies} onClose={vi.fn()} />)
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<string, string>,
description: {} as Record<string, string>,
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(<InstallBundle fromDSLPayload={mixedDependencies} onClose={vi.fn()} />)
expect(screen.getByTestId('plugins-count')).toHaveTextContent('3')
})
it('should handle rapid step changes', () => {
render(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} />)
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(
<InstallBundle {...defaultProps} installType={type} />,
)
expect(screen.getByTestId('ready-to-install')).toBeInTheDocument()
unmount()
})
})
})
// ================================
// Modal Integration Tests
// ================================
describe('Modal Integration', () => {
it('should render modal with title', () => {
render(<InstallBundle {...defaultProps} />)
// Verify modal renders with title
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
})
it('should render modal with closable behavior', () => {
render(<InstallBundle {...defaultProps} />)
// Modal should render the content including the ReadyToInstall component
expect(screen.getByTestId('ready-to-install')).toBeInTheDocument()
})
it('should display title in modal header', () => {
render(<InstallBundle {...defaultProps} />)
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(<InstallBundle {...defaultProps} installType={InstallType.fromMarketplace} />)
expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall)
})
it('should set initial step based on installType for fromLocal', () => {
render(<InstallBundle {...defaultProps} installType={InstallType.fromLocal} />)
expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.uploading)
})
it('should set initial step based on installType for fromDSL', () => {
render(<InstallBundle {...defaultProps} installType={InstallType.fromDSL} />)
expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.uploading)
})
it('should use default installType when not provided', () => {
render(<InstallBundle fromDSLPayload={defaultProps.fromDSLPayload} onClose={defaultProps.onClose} />)
// 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(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} onClose={onClose} />)
// 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(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} />)
expect(screen.getByTestId('current-step')).toHaveTextContent(InstallStep.readyToInstall)
})
it('should pass onStepChange to ReadyToInstall', () => {
render(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} />)
fireEvent.click(screen.getByTestId('start-install-btn'))
expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalled()
})
it('should pass setIsInstalling to ReadyToInstall', () => {
render(<InstallBundle {...defaultProps} />)
fireEvent.click(screen.getByTestId('set-installing-true'))
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(true)
})
it('should pass allPlugins (fromDSLPayload) to ReadyToInstall', () => {
const plugins = createMockDependencies()
render(<InstallBundle fromDSLPayload={plugins} onClose={vi.fn()} />)
expect(screen.getByTestId('plugins-count')).toHaveTextContent(String(plugins.length))
})
it('should pass onClose to ReadyToInstall', () => {
const onClose = vi.fn()
render(<InstallBundle {...defaultProps} onClose={onClose} />)
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(<InstallBundle {...defaultProps} />)
// Default step (readyToInstall) -> installPlugin title
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
})
it('should update title when step changes', () => {
render(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle fromDSLPayload={null} onClose={vi.fn()} />)
// 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(<InstallBundle fromDSLPayload={undefined} onClose={vi.fn()} />)
// 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(<InstallBundle {...defaultProps} />)
// 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(<InstallBundle {...defaultProps} />)
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(
<InstallBundle {...defaultProps} installType={InstallType.fromMarketplace} />,
)
expect(screen.getByTestId('ready-to-install')).toBeInTheDocument()
unmount1()
// fromLocal
const { unmount: unmount2 } = render(
<InstallBundle {...defaultProps} installType={InstallType.fromLocal} />,
)
expect(screen.getByTestId('ready-to-install')).toBeInTheDocument()
unmount2()
// fromDSL
const { unmount: unmount3 } = render(
<InstallBundle {...defaultProps} installType={InstallType.fromDSL} />,
)
expect(screen.getByTestId('ready-to-install')).toBeInTheDocument()
unmount3()
})
it('should maintain modal structure across step changes', () => {
render(<InstallBundle {...defaultProps} />)
// 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(<Installed {...defaultInstalledProps} />)
// 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(<Installed list={plugins} installStatus={statuses} onCancel={vi.fn()} />)
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
})
it('should not render close button when isHideButton is true', () => {
render(<Installed {...defaultInstalledProps} isHideButton={true} />)
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(<Installed {...defaultInstalledProps} onCancel={onCancel} />)
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(<Installed list={[]} installStatus={[]} onCancel={vi.fn()} />)
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(<Installed list={plugins} installStatus={statuses} onCancel={vi.fn()} />)
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(<LoadedItem {...defaultLoadedItemProps} />)
expect(getCheckbox()).toBeInTheDocument()
})
it('should render checkbox with check icon when checked prop is true', () => {
render(<LoadedItem {...defaultLoadedItemProps} checked={true} />)
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(<LoadedItem {...defaultLoadedItemProps} checked={false} />)
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(<LoadedItem {...defaultLoadedItemProps} onCheckedChange={onCheckedChange} />)
fireEvent.click(getCheckbox())
expect(onCheckedChange).toHaveBeenCalledWith(defaultLoadedItemProps.payload)
})
})
// ================================
// Props Tests
// ================================
describe('Props', () => {
it('should handle isFromMarketPlace prop', () => {
render(<LoadedItem {...defaultLoadedItemProps} isFromMarketPlace={true} />)
expect(getCheckbox()).toBeInTheDocument()
})
it('should display version info when payload has version', () => {
const pluginWithVersion = createMockPlugin({ version: '2.0.0' })
render(<LoadedItem {...defaultLoadedItemProps} payload={pluginWithVersion} />)
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(<MarketplaceItem {...defaultMarketplaceItemProps} />)
expect(getCheckbox()).toBeInTheDocument()
})
it('should render Loading when payload is undefined', () => {
render(<MarketplaceItem {...defaultMarketplaceItemProps} payload={undefined} />)
// 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(<MarketplaceItem {...defaultMarketplaceItemProps} version="2.0.0" />)
expect(getCheckbox()).toBeInTheDocument()
})
it('should pass checked state to LoadedItem', () => {
render(<MarketplaceItem {...defaultMarketplaceItemProps} checked={true} />)
// 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(<MarketplaceItem {...defaultMarketplaceItemProps} onCheckedChange={onCheckedChange} />)
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(<PackageItem {...defaultPackageItemProps} />)
expect(getCheckbox()).toBeInTheDocument()
})
it('should render LoadingError when manifest is missing', () => {
const invalidPayload = {
type: 'package',
value: { unique_identifier: 'test' },
} as PackageDependency
render(<PackageItem {...defaultPackageItemProps} payload={invalidPayload} />)
// 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(<PackageItem {...defaultPackageItemProps} isFromMarketPlace={true} />)
expect(getCheckbox()).toBeInTheDocument()
})
it('should pass checked state to LoadedItem', () => {
render(<PackageItem {...defaultPackageItemProps} checked={true} />)
// 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(<PackageItem {...defaultPackageItemProps} onCheckedChange={onCheckedChange} />)
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(<GithubItem {...defaultGithubItemProps} />)
// 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(<GithubItem {...defaultGithubItemProps} />)
// 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(<GithubItem {...defaultGithubItemProps} onFetchedPayload={onFetchedPayload} />)
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(<GithubItem {...defaultGithubItemProps} onFetchError={onFetchError} />)
await waitFor(() => {
expect(onFetchError).toHaveBeenCalled()
})
})
})
// ================================
// Props Tests
// ================================
describe('Props', () => {
it('should pass dependency info to useUploadGitHub', () => {
const dependency = createMockGitHubDependency()
render(<GithubItem {...defaultGithubItemProps} dependency={dependency} />)
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')
})
})
})