test: add some tests for marketplace (#30326)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
Coding On Star
2025-12-30 09:21:19 +08:00
committed by GitHub
parent 20944e7e1a
commit 43758ec85d
47 changed files with 37836 additions and 4 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,525 @@
import type { Plugin, PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum, TaskStatus } from '../../../types'
import Loaded from './loaded'
// Mock dependencies
const mockUseCheckInstalled = vi.fn()
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: (params: { pluginIds: string[], enabled: boolean }) => mockUseCheckInstalled(params),
}))
const mockUpdateFromGitHub = vi.fn()
vi.mock('@/service/plugins', () => ({
updateFromGitHub: (...args: unknown[]) => mockUpdateFromGitHub(...args),
}))
const mockInstallPackageFromGitHub = vi.fn()
const mockHandleRefetch = vi.fn()
vi.mock('@/service/use-plugins', () => ({
useInstallPackageFromGitHub: () => ({ mutateAsync: mockInstallPackageFromGitHub }),
usePluginTaskList: () => ({ handleRefetch: mockHandleRefetch }),
}))
const mockCheck = vi.fn()
vi.mock('../../base/check-task-status', () => ({
default: () => ({ check: mockCheck }),
}))
// Mock Card component
vi.mock('../../../card', () => ({
default: ({ payload, titleLeft }: { payload: Plugin, titleLeft?: React.ReactNode }) => (
<div data-testid="plugin-card">
<span data-testid="card-name">{payload.name}</span>
{titleLeft && <span data-testid="title-left">{titleLeft}</span>}
</div>
),
}))
// Mock Version component
vi.mock('../../base/version', () => ({
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
hasInstalled: boolean
installedVersion?: string
toInstallVersion: string
}) => (
<span data-testid="version-info">
{hasInstalled ? `Update from ${installedVersion} to ${toInstallVersion}` : `Install ${toInstallVersion}`}
</span>
),
}))
// Factory functions
const createMockPayload = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
plugin_unique_identifier: 'test-uid',
version: '1.0.0',
author: 'test-author',
icon: 'icon.png',
name: 'Test Plugin',
category: PluginCategoryEnum.tool,
label: { 'en-US': 'Test' } as PluginDeclaration['label'],
description: { 'en-US': 'Test Description' } as PluginDeclaration['description'],
created_at: '2024-01-01',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {} as PluginDeclaration['trigger'],
...overrides,
})
const createMockPluginPayload = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'test-org',
name: 'Test Plugin',
plugin_id: 'test-plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'test-pkg',
icon: 'icon.png',
verified: true,
label: { 'en-US': 'Test' },
brief: { 'en-US': 'Brief' },
description: { 'en-US': 'Description' },
introduction: 'Intro',
repository: '',
category: PluginCategoryEnum.tool,
install_count: 100,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'langgenius' },
from: 'github',
...overrides,
})
const createUpdatePayload = (): UpdateFromGitHubPayload => ({
originalPackageInfo: {
id: 'original-id',
repo: 'owner/repo',
version: 'v0.9.0',
package: 'plugin.zip',
releases: [],
},
})
describe('Loaded', () => {
const defaultProps = {
updatePayload: undefined,
uniqueIdentifier: 'test-unique-id',
payload: createMockPayload() as PluginDeclaration | Plugin,
repoUrl: 'https://github.com/owner/repo',
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onBack: vi.fn(),
onStartToInstall: vi.fn(),
onInstalled: vi.fn(),
onFailed: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockUseCheckInstalled.mockReturnValue({
installedInfo: {},
isLoading: false,
})
mockUpdateFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null })
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render ready to install message', () => {
render(<Loaded {...defaultProps} />)
expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument()
})
it('should render plugin card', () => {
render(<Loaded {...defaultProps} />)
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
})
it('should render back button when not installing', () => {
render(<Loaded {...defaultProps} />)
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument()
})
it('should render install button', () => {
render(<Loaded {...defaultProps} />)
expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).toBeInTheDocument()
})
it('should show version info in card title', () => {
render(<Loaded {...defaultProps} />)
expect(screen.getByTestId('version-info')).toBeInTheDocument()
})
})
// ================================
// Props Tests
// ================================
describe('Props', () => {
it('should display plugin name from payload', () => {
render(<Loaded {...defaultProps} />)
expect(screen.getByTestId('card-name')).toHaveTextContent('Test Plugin')
})
it('should pass correct version to Version component', () => {
render(<Loaded {...defaultProps} payload={createMockPayload({ version: '2.0.0' })} />)
expect(screen.getByTestId('version-info')).toHaveTextContent('Install 2.0.0')
})
})
// ================================
// Button State Tests
// ================================
describe('Button State', () => {
it('should disable install button while loading', () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: {},
isLoading: true,
})
render(<Loaded {...defaultProps} />)
expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).toBeDisabled()
})
it('should enable install button when not loading', () => {
render(<Loaded {...defaultProps} />)
expect(screen.getByRole('button', { name: /plugin.installModal.install/i })).not.toBeDisabled()
})
})
// ================================
// User Interactions Tests
// ================================
describe('User Interactions', () => {
it('should call onBack when back button is clicked', () => {
const onBack = vi.fn()
render(<Loaded {...defaultProps} onBack={onBack} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' }))
expect(onBack).toHaveBeenCalledTimes(1)
})
it('should call onStartToInstall when install starts', async () => {
const onStartToInstall = vi.fn()
render(<Loaded {...defaultProps} onStartToInstall={onStartToInstall} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(onStartToInstall).toHaveBeenCalledTimes(1)
})
})
})
// ================================
// Installation Flow Tests
// ================================
describe('Installation Flows', () => {
it('should call installPackageFromGitHub for fresh install', async () => {
const onInstalled = vi.fn()
render(<Loaded {...defaultProps} onInstalled={onInstalled} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(mockInstallPackageFromGitHub).toHaveBeenCalledWith({
repoUrl: 'owner/repo',
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
uniqueIdentifier: 'test-unique-id',
})
})
})
it('should call updateFromGitHub when updatePayload is provided', async () => {
const updatePayload = createUpdatePayload()
render(<Loaded {...defaultProps} updatePayload={updatePayload} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(mockUpdateFromGitHub).toHaveBeenCalledWith(
'owner/repo',
'v1.0.0',
'plugin.zip',
'original-id',
'test-unique-id',
)
})
})
it('should call updateFromGitHub when plugin is already installed', async () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: {
'test-plugin-id': {
installedVersion: '0.9.0',
uniqueIdentifier: 'installed-uid',
},
},
isLoading: false,
})
render(<Loaded {...defaultProps} payload={createMockPluginPayload()} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(mockUpdateFromGitHub).toHaveBeenCalledWith(
'owner/repo',
'v1.0.0',
'plugin.zip',
'installed-uid',
'test-unique-id',
)
})
})
it('should call onInstalled when installation completes immediately', async () => {
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
const onInstalled = vi.fn()
render(<Loaded {...defaultProps} onInstalled={onInstalled} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(onInstalled).toHaveBeenCalled()
})
})
it('should check task status when not immediately installed', async () => {
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
render(<Loaded {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(mockHandleRefetch).toHaveBeenCalled()
expect(mockCheck).toHaveBeenCalledWith({
taskId: 'task-1',
pluginUniqueIdentifier: 'test-unique-id',
})
})
})
it('should call onInstalled with true when task succeeds', async () => {
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null })
const onInstalled = vi.fn()
render(<Loaded {...defaultProps} onInstalled={onInstalled} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(onInstalled).toHaveBeenCalledWith(true)
})
})
})
// ================================
// Error Handling Tests
// ================================
describe('Error Handling', () => {
it('should call onFailed when task fails', async () => {
mockInstallPackageFromGitHub.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
mockCheck.mockResolvedValue({ status: TaskStatus.failed, error: 'Installation failed' })
const onFailed = vi.fn()
render(<Loaded {...defaultProps} onFailed={onFailed} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith('Installation failed')
})
})
it('should call onFailed with string error', async () => {
mockInstallPackageFromGitHub.mockRejectedValue('String error message')
const onFailed = vi.fn()
render(<Loaded {...defaultProps} onFailed={onFailed} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith('String error message')
})
})
it('should call onFailed without message for non-string errors', async () => {
mockInstallPackageFromGitHub.mockRejectedValue(new Error('Error object'))
const onFailed = vi.fn()
render(<Loaded {...defaultProps} onFailed={onFailed} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith()
})
})
})
// ================================
// Auto-install Effect Tests
// ================================
describe('Auto-install Effect', () => {
it('should call onInstalled when already installed with same identifier', () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: {
'test-plugin-id': {
installedVersion: '1.0.0',
uniqueIdentifier: 'test-unique-id',
},
},
isLoading: false,
})
const onInstalled = vi.fn()
render(<Loaded {...defaultProps} payload={createMockPluginPayload()} onInstalled={onInstalled} />)
expect(onInstalled).toHaveBeenCalled()
})
it('should not call onInstalled when identifiers differ', () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: {
'test-plugin-id': {
installedVersion: '1.0.0',
uniqueIdentifier: 'different-uid',
},
},
isLoading: false,
})
const onInstalled = vi.fn()
render(<Loaded {...defaultProps} payload={createMockPluginPayload()} onInstalled={onInstalled} />)
expect(onInstalled).not.toHaveBeenCalled()
})
})
// ================================
// Installing State Tests
// ================================
describe('Installing State', () => {
it('should hide back button while installing', async () => {
let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void
mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => {
resolveInstall = resolve
}))
render(<Loaded {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
})
resolveInstall!({ all_installed: true, task_id: 'task-1' })
})
it('should show installing text while installing', async () => {
let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void
mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => {
resolveInstall = resolve
}))
render(<Loaded {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument()
})
resolveInstall!({ all_installed: true, task_id: 'task-1' })
})
it('should not trigger install twice when already installing', async () => {
let resolveInstall: (value: { all_installed: boolean, task_id: string }) => void
mockInstallPackageFromGitHub.mockImplementation(() => new Promise((resolve) => {
resolveInstall = resolve
}))
render(<Loaded {...defaultProps} />)
const installButton = screen.getByRole('button', { name: /plugin.installModal.install/i })
// Click twice
fireEvent.click(installButton)
fireEvent.click(installButton)
await waitFor(() => {
expect(mockInstallPackageFromGitHub).toHaveBeenCalledTimes(1)
})
resolveInstall!({ all_installed: true, task_id: 'task-1' })
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle missing onStartToInstall callback', async () => {
render(<Loaded {...defaultProps} onStartToInstall={undefined} />)
// Should not throw when callback is undefined
expect(() => {
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.install/i }))
}).not.toThrow()
await waitFor(() => {
expect(mockInstallPackageFromGitHub).toHaveBeenCalled()
})
})
it('should handle plugin without plugin_id', () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: {},
isLoading: false,
})
render(<Loaded {...defaultProps} payload={createMockPayload()} />)
expect(mockUseCheckInstalled).toHaveBeenCalledWith({
pluginIds: [undefined],
enabled: false,
})
})
it('should preserve state after component update', () => {
const { rerender } = render(<Loaded {...defaultProps} />)
rerender(<Loaded {...defaultProps} />)
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
})
})
})

View File

@ -16,7 +16,7 @@ import Version from '../../base/version'
import { parseGitHubUrl, pluginManifestToCardPluginProps } from '../../utils'
type LoadedProps = {
updatePayload: UpdateFromGitHubPayload
updatePayload?: UpdateFromGitHubPayload
uniqueIdentifier: string
payload: PluginDeclaration | Plugin
repoUrl: string

View File

@ -0,0 +1,877 @@
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
import type { Item } from '@/app/components/base/select'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../types'
import SelectPackage from './selectPackage'
// Mock the useGitHubUpload hook
const mockHandleUpload = vi.fn()
vi.mock('../../hooks', () => ({
useGitHubUpload: () => ({ handleUpload: mockHandleUpload }),
}))
// Factory functions
const createMockManifest = (): PluginDeclaration => ({
plugin_unique_identifier: 'test-uid',
version: '1.0.0',
author: 'test-author',
icon: 'icon.png',
name: 'Test Plugin',
category: PluginCategoryEnum.tool,
label: { 'en-US': 'Test' } as PluginDeclaration['label'],
description: { 'en-US': 'Test Description' } as PluginDeclaration['description'],
created_at: '2024-01-01',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {} as PluginDeclaration['trigger'],
})
const createVersions = (): Item[] => [
{ value: 'v1.0.0', name: 'v1.0.0' },
{ value: 'v0.9.0', name: 'v0.9.0' },
]
const createPackages = (): Item[] => [
{ value: 'plugin.zip', name: 'plugin.zip' },
{ value: 'plugin.tar.gz', name: 'plugin.tar.gz' },
]
const createUpdatePayload = (): UpdateFromGitHubPayload => ({
originalPackageInfo: {
id: 'original-id',
repo: 'owner/repo',
version: 'v0.9.0',
package: 'plugin.zip',
releases: [],
},
})
// Test props type - updatePayload is optional for testing
type TestProps = {
updatePayload?: UpdateFromGitHubPayload
repoUrl?: string
selectedVersion?: string
versions?: Item[]
onSelectVersion?: (item: Item) => void
selectedPackage?: string
packages?: Item[]
onSelectPackage?: (item: Item) => void
onUploaded?: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void
onFailed?: (errorMsg: string) => void
onBack?: () => void
}
describe('SelectPackage', () => {
const createDefaultProps = () => ({
updatePayload: undefined as UpdateFromGitHubPayload | undefined,
repoUrl: 'https://github.com/owner/repo',
selectedVersion: '',
versions: createVersions(),
onSelectVersion: vi.fn() as (item: Item) => void,
selectedPackage: '',
packages: createPackages(),
onSelectPackage: vi.fn() as (item: Item) => void,
onUploaded: vi.fn() as (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void,
onFailed: vi.fn() as (errorMsg: string) => void,
onBack: vi.fn() as () => void,
})
// Helper function to render with proper type handling
const renderSelectPackage = (overrides: TestProps = {}) => {
const props = { ...createDefaultProps(), ...overrides }
// Cast to any to bypass strict type checking since component accepts optional updatePayload
return render(<SelectPackage {...(props as Parameters<typeof SelectPackage>[0])} />)
}
beforeEach(() => {
vi.clearAllMocks()
mockHandleUpload.mockReset()
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render version label', () => {
renderSelectPackage()
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
})
it('should render package label', () => {
renderSelectPackage()
expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument()
})
it('should render back button when not in edit mode', () => {
renderSelectPackage({ updatePayload: undefined })
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument()
})
it('should not render back button when in edit mode', () => {
renderSelectPackage({ updatePayload: createUpdatePayload() })
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
})
it('should render next button', () => {
renderSelectPackage()
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeInTheDocument()
})
})
// ================================
// Props Tests
// ================================
describe('Props', () => {
it('should pass selectedVersion to PortalSelect', () => {
renderSelectPackage({ selectedVersion: 'v1.0.0' })
// PortalSelect should display the selected version
expect(screen.getByText('v1.0.0')).toBeInTheDocument()
})
it('should pass selectedPackage to PortalSelect', () => {
renderSelectPackage({ selectedPackage: 'plugin.zip' })
expect(screen.getByText('plugin.zip')).toBeInTheDocument()
})
it('should show installed version badge when updatePayload version differs', () => {
renderSelectPackage({
updatePayload: createUpdatePayload(),
selectedVersion: 'v1.0.0',
})
expect(screen.getByText(/v0\.9\.0\s*->\s*v1\.0\.0/)).toBeInTheDocument()
})
})
// ================================
// Button State Tests
// ================================
describe('Button State', () => {
it('should disable next button when no version selected', () => {
renderSelectPackage({ selectedVersion: '', selectedPackage: '' })
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
})
it('should disable next button when version selected but no package', () => {
renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: '' })
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
})
it('should enable next button when both version and package selected', () => {
renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: 'plugin.zip' })
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
})
})
// ================================
// User Interactions Tests
// ================================
describe('User Interactions', () => {
it('should call onBack when back button is clicked', () => {
const onBack = vi.fn()
renderSelectPackage({ onBack })
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' }))
expect(onBack).toHaveBeenCalledTimes(1)
})
it('should call handleUploadPackage when next button is clicked', async () => {
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() })
})
const onUploaded = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onUploaded,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(mockHandleUpload).toHaveBeenCalledTimes(1)
expect(mockHandleUpload).toHaveBeenCalledWith(
'owner/repo',
'v1.0.0',
'plugin.zip',
expect.any(Function),
)
})
})
it('should not invoke upload when next button is disabled', () => {
renderSelectPackage({ selectedVersion: '', selectedPackage: '' })
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
expect(mockHandleUpload).not.toHaveBeenCalled()
})
})
// ================================
// Upload Handling Tests
// ================================
describe('Upload Handling', () => {
it('should call onUploaded with correct data on successful upload', async () => {
const mockManifest = createMockManifest()
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
onSuccess({ unique_identifier: 'test-uid', manifest: mockManifest })
})
const onUploaded = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onUploaded,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(onUploaded).toHaveBeenCalledWith({
uniqueIdentifier: 'test-uid',
manifest: mockManifest,
})
})
})
it('should call onFailed with response message on upload error', async () => {
mockHandleUpload.mockRejectedValue({ response: { message: 'API Error' } })
const onFailed = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onFailed,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith('API Error')
})
})
it('should call onFailed with default message when no response message', async () => {
mockHandleUpload.mockRejectedValue(new Error('Network error'))
const onFailed = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onFailed,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
})
})
it('should not call upload twice when already uploading', async () => {
let resolveUpload: (value?: unknown) => void
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
resolveUpload = resolve
}))
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
})
const nextButton = screen.getByRole('button', { name: 'plugin.installModal.next' })
// Click twice rapidly - this tests the isUploading guard at line 49-50
// The first click starts the upload, the second should be ignored
fireEvent.click(nextButton)
fireEvent.click(nextButton)
await waitFor(() => {
expect(mockHandleUpload).toHaveBeenCalledTimes(1)
})
// Resolve the upload
resolveUpload!()
})
it('should disable back button while uploading', async () => {
let resolveUpload: (value?: unknown) => void
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
resolveUpload = resolve
}))
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled()
})
resolveUpload!()
})
it('should strip github.com prefix from repoUrl', async () => {
mockHandleUpload.mockResolvedValue({})
renderSelectPackage({
repoUrl: 'https://github.com/myorg/myrepo',
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(mockHandleUpload).toHaveBeenCalledWith(
'myorg/myrepo',
expect.any(String),
expect.any(String),
expect.any(Function),
)
})
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle empty versions array', () => {
renderSelectPackage({ versions: [] })
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
})
it('should handle empty packages array', () => {
renderSelectPackage({ packages: [] })
expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument()
})
it('should handle updatePayload with installed version', () => {
renderSelectPackage({ updatePayload: createUpdatePayload() })
// Should not show back button in edit mode
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
})
it('should re-enable buttons after upload completes', async () => {
mockHandleUpload.mockResolvedValue({})
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
})
})
it('should re-enable buttons after upload fails', async () => {
mockHandleUpload.mockRejectedValue(new Error('Upload failed'))
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
})
})
})
// ================================
// PortalSelect Readonly State Tests
// ================================
describe('PortalSelect Readonly State', () => {
it('should make package select readonly when no version selected', () => {
renderSelectPackage({ selectedVersion: '' })
// When no version is selected, package select should be readonly
// This is tested by verifying the component renders correctly
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
expect(trigger).toHaveClass('cursor-not-allowed')
})
it('should make package select active when version is selected', () => {
renderSelectPackage({ selectedVersion: 'v1.0.0' })
// When version is selected, package select should be active
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
expect(trigger).toHaveClass('cursor-pointer')
})
})
// ================================
// installedValue Props Tests
// ================================
describe('installedValue Props', () => {
it('should pass installedValue when updatePayload is provided', () => {
const updatePayload = createUpdatePayload()
renderSelectPackage({ updatePayload })
// The installed version should be passed to PortalSelect
// updatePayload.originalPackageInfo.version = 'v0.9.0'
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
})
it('should not pass installedValue when updatePayload is undefined', () => {
renderSelectPackage({ updatePayload: undefined })
// No installed version indicator
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
})
it('should handle updatePayload with different version value', () => {
const updatePayload = createUpdatePayload()
updatePayload.originalPackageInfo.version = 'v2.0.0'
renderSelectPackage({ updatePayload })
// Should render without errors
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
})
it('should show installed badge in version list', () => {
const updatePayload = createUpdatePayload()
renderSelectPackage({ updatePayload, selectedVersion: '' })
fireEvent.click(screen.getByText('plugin.installFromGitHub.selectVersionPlaceholder'))
expect(screen.getByText('INSTALLED')).toBeInTheDocument()
})
})
// ================================
// Next Button Disabled State Combinations
// ================================
describe('Next Button Disabled State Combinations', () => {
it('should disable next button when only version is missing', () => {
renderSelectPackage({ selectedVersion: '', selectedPackage: 'plugin.zip' })
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
})
it('should disable next button when only package is missing', () => {
renderSelectPackage({ selectedVersion: 'v1.0.0', selectedPackage: '' })
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
})
it('should disable next button when both are missing', () => {
renderSelectPackage({ selectedVersion: '', selectedPackage: '' })
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
})
it('should disable next button when uploading even with valid selections', async () => {
let resolveUpload: (value?: unknown) => void
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
resolveUpload = resolve
}))
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
})
resolveUpload!()
})
})
// ================================
// RepoUrl Format Handling Tests
// ================================
describe('RepoUrl Format Handling', () => {
it('should handle repoUrl without trailing slash', async () => {
mockHandleUpload.mockResolvedValue({})
renderSelectPackage({
repoUrl: 'https://github.com/owner/repo',
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(mockHandleUpload).toHaveBeenCalledWith(
'owner/repo',
'v1.0.0',
'plugin.zip',
expect.any(Function),
)
})
})
it('should handle repoUrl with different org/repo combinations', async () => {
mockHandleUpload.mockResolvedValue({})
renderSelectPackage({
repoUrl: 'https://github.com/my-organization/my-plugin-repo',
selectedVersion: 'v2.0.0',
selectedPackage: 'build.tar.gz',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(mockHandleUpload).toHaveBeenCalledWith(
'my-organization/my-plugin-repo',
'v2.0.0',
'build.tar.gz',
expect.any(Function),
)
})
})
it('should pass through repoUrl without github prefix', async () => {
mockHandleUpload.mockResolvedValue({})
renderSelectPackage({
repoUrl: 'plain-org/plain-repo',
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(mockHandleUpload).toHaveBeenCalledWith(
'plain-org/plain-repo',
'v1.0.0',
'plugin.zip',
expect.any(Function),
)
})
})
})
// ================================
// isEdit Mode Comprehensive Tests
// ================================
describe('isEdit Mode Comprehensive', () => {
it('should set isEdit to true when updatePayload is truthy', () => {
const updatePayload = createUpdatePayload()
renderSelectPackage({ updatePayload })
// Back button should not be rendered in edit mode
expect(screen.queryByRole('button', { name: 'plugin.installModal.back' })).not.toBeInTheDocument()
})
it('should set isEdit to false when updatePayload is undefined', () => {
renderSelectPackage({ updatePayload: undefined })
// Back button should be rendered when not in edit mode
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeInTheDocument()
})
it('should allow upload in edit mode without back button', async () => {
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() })
})
const onUploaded = vi.fn()
renderSelectPackage({
updatePayload: createUpdatePayload(),
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onUploaded,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(onUploaded).toHaveBeenCalled()
})
})
})
// ================================
// Error Response Handling Tests
// ================================
describe('Error Response Handling', () => {
it('should handle error with response.message property', async () => {
mockHandleUpload.mockRejectedValue({ response: { message: 'Custom API Error' } })
const onFailed = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onFailed,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith('Custom API Error')
})
})
it('should handle error with empty response object', async () => {
mockHandleUpload.mockRejectedValue({ response: {} })
const onFailed = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onFailed,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
})
})
it('should handle error without response property', async () => {
mockHandleUpload.mockRejectedValue({ code: 'NETWORK_ERROR' })
const onFailed = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onFailed,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
})
})
it('should handle error with response but no message', async () => {
mockHandleUpload.mockRejectedValue({ response: { status: 500 } })
const onFailed = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onFailed,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
})
})
it('should handle string error', async () => {
mockHandleUpload.mockRejectedValue('String error message')
const onFailed = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onFailed,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith('plugin.installFromGitHub.uploadFailed')
})
})
})
// ================================
// Callback Props Tests
// ================================
describe('Callback Props', () => {
it('should pass onSelectVersion to PortalSelect', () => {
const onSelectVersion = vi.fn()
renderSelectPackage({ onSelectVersion })
// The callback is passed to PortalSelect, which is a base component
// We verify it's rendered correctly
expect(screen.getByText('plugin.installFromGitHub.selectVersion')).toBeInTheDocument()
})
it('should pass onSelectPackage to PortalSelect', () => {
const onSelectPackage = vi.fn()
renderSelectPackage({ onSelectPackage })
// The callback is passed to PortalSelect, which is a base component
expect(screen.getByText('plugin.installFromGitHub.selectPackage')).toBeInTheDocument()
})
})
// ================================
// Upload State Management Tests
// ================================
describe('Upload State Management', () => {
it('should set isUploading to true when upload starts', async () => {
let resolveUpload: (value?: unknown) => void
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
resolveUpload = resolve
}))
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
// Both buttons should be disabled during upload
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled()
})
resolveUpload!()
})
it('should set isUploading to false after successful upload', async () => {
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
onSuccess({ unique_identifier: 'uid', manifest: createMockManifest() })
})
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
})
})
it('should set isUploading to false after failed upload', async () => {
mockHandleUpload.mockRejectedValue(new Error('Upload failed'))
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).not.toBeDisabled()
})
})
it('should not allow back button click while uploading', async () => {
let resolveUpload: (value?: unknown) => void
mockHandleUpload.mockImplementation(() => new Promise((resolve) => {
resolveUpload = resolve
}))
const onBack = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onBack,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.installModal.back' })).toBeDisabled()
})
// Try to click back button while disabled
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.back' }))
// onBack should not be called
expect(onBack).not.toHaveBeenCalled()
resolveUpload!()
})
})
// ================================
// handleUpload Callback Tests
// ================================
describe('handleUpload Callback', () => {
it('should invoke onSuccess callback with correct data structure', async () => {
const mockManifest = createMockManifest()
mockHandleUpload.mockImplementation(async (_repo, _version, _package, onSuccess) => {
onSuccess({
unique_identifier: 'test-unique-identifier',
manifest: mockManifest,
})
})
const onUploaded = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
selectedPackage: 'plugin.zip',
onUploaded,
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(onUploaded).toHaveBeenCalledWith({
uniqueIdentifier: 'test-unique-identifier',
manifest: mockManifest,
})
})
})
it('should pass correct repo, version, and package to handleUpload', async () => {
mockHandleUpload.mockResolvedValue({})
renderSelectPackage({
repoUrl: 'https://github.com/test-org/test-repo',
selectedVersion: 'v3.0.0',
selectedPackage: 'release.zip',
})
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
await waitFor(() => {
expect(mockHandleUpload).toHaveBeenCalledWith(
'test-org/test-repo',
'v3.0.0',
'release.zip',
expect.any(Function),
)
})
})
})
})

View File

@ -0,0 +1,180 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SetURL from './setURL'
describe('SetURL', () => {
const defaultProps = {
repoUrl: '',
onChange: vi.fn(),
onNext: vi.fn(),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render label with GitHub repo text', () => {
render(<SetURL {...defaultProps} />)
expect(screen.getByText('plugin.installFromGitHub.gitHubRepo')).toBeInTheDocument()
})
it('should render input field with correct attributes', () => {
render(<SetURL {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('type', 'url')
expect(input).toHaveAttribute('id', 'repoUrl')
expect(input).toHaveAttribute('name', 'repoUrl')
expect(input).toHaveAttribute('placeholder', 'Please enter GitHub repo URL')
})
it('should render cancel button', () => {
render(<SetURL {...defaultProps} />)
expect(screen.getByRole('button', { name: 'plugin.installModal.cancel' })).toBeInTheDocument()
})
it('should render next button', () => {
render(<SetURL {...defaultProps} />)
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeInTheDocument()
})
it('should associate label with input field', () => {
render(<SetURL {...defaultProps} />)
const input = screen.getByLabelText('plugin.installFromGitHub.gitHubRepo')
expect(input).toBeInTheDocument()
})
})
// ================================
// Props Tests
// ================================
describe('Props', () => {
it('should display repoUrl value in input', () => {
render(<SetURL {...defaultProps} repoUrl="https://github.com/test/repo" />)
expect(screen.getByRole('textbox')).toHaveValue('https://github.com/test/repo')
})
it('should display empty string when repoUrl is empty', () => {
render(<SetURL {...defaultProps} repoUrl="" />)
expect(screen.getByRole('textbox')).toHaveValue('')
})
})
// ================================
// User Interactions Tests
// ================================
describe('User Interactions', () => {
it('should call onChange when input value changes', () => {
const onChange = vi.fn()
render(<SetURL {...defaultProps} onChange={onChange} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'https://github.com/owner/repo' } })
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('https://github.com/owner/repo')
})
it('should call onCancel when cancel button is clicked', () => {
const onCancel = vi.fn()
render(<SetURL {...defaultProps} onCancel={onCancel} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onNext when next button is clicked', () => {
const onNext = vi.fn()
render(<SetURL {...defaultProps} repoUrl="https://github.com/test/repo" onNext={onNext} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
expect(onNext).toHaveBeenCalledTimes(1)
})
})
// ================================
// Button State Tests
// ================================
describe('Button State', () => {
it('should disable next button when repoUrl is empty', () => {
render(<SetURL {...defaultProps} repoUrl="" />)
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
})
it('should disable next button when repoUrl is only whitespace', () => {
render(<SetURL {...defaultProps} repoUrl=" " />)
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).toBeDisabled()
})
it('should enable next button when repoUrl has content', () => {
render(<SetURL {...defaultProps} repoUrl="https://github.com/test/repo" />)
expect(screen.getByRole('button', { name: 'plugin.installModal.next' })).not.toBeDisabled()
})
it('should not disable cancel button regardless of repoUrl', () => {
render(<SetURL {...defaultProps} repoUrl="" />)
expect(screen.getByRole('button', { name: 'plugin.installModal.cancel' })).not.toBeDisabled()
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle URL with special characters', () => {
const onChange = vi.fn()
render(<SetURL {...defaultProps} onChange={onChange} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'https://github.com/test-org/repo_name-123' } })
expect(onChange).toHaveBeenCalledWith('https://github.com/test-org/repo_name-123')
})
it('should handle very long URLs', () => {
const longUrl = `https://github.com/${'a'.repeat(100)}/${'b'.repeat(100)}`
render(<SetURL {...defaultProps} repoUrl={longUrl} />)
expect(screen.getByRole('textbox')).toHaveValue(longUrl)
})
it('should handle onChange with empty string', () => {
const onChange = vi.fn()
render(<SetURL {...defaultProps} repoUrl="some-value" onChange={onChange} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith('')
})
it('should preserve callback references on rerender', () => {
const onNext = vi.fn()
const { rerender } = render(<SetURL {...defaultProps} repoUrl="https://github.com/a/b" onNext={onNext} />)
rerender(<SetURL {...defaultProps} repoUrl="https://github.com/a/b" onNext={onNext} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.next' }))
expect(onNext).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,471 @@
import type { PluginDeclaration } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InstallStep, PluginCategoryEnum } from '../../types'
import ReadyToInstall from './ready-to-install'
// Factory function for test data
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
plugin_unique_identifier: 'test-plugin-uid',
version: '1.0.0',
author: 'test-author',
icon: 'test-icon.png',
name: 'Test Plugin',
category: PluginCategoryEnum.tool,
label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'],
description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'],
created_at: '2024-01-01T00:00:00Z',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {} as PluginDeclaration['trigger'],
...overrides,
})
// Mock external dependencies
const mockRefreshPluginList = vi.fn()
vi.mock('../hooks/use-refresh-plugin-list', () => ({
default: () => ({
refreshPluginList: mockRefreshPluginList,
}),
}))
// Mock Install component
let _installOnInstalled: ((notRefresh?: boolean) => void) | null = null
let _installOnFailed: ((message?: string) => void) | null = null
let _installOnCancel: (() => void) | null = null
let _installOnStartToInstall: (() => void) | null = null
vi.mock('./steps/install', () => ({
default: ({
uniqueIdentifier,
payload,
onCancel,
onStartToInstall,
onInstalled,
onFailed,
}: {
uniqueIdentifier: string
payload: PluginDeclaration
onCancel: () => void
onStartToInstall?: () => void
onInstalled: (notRefresh?: boolean) => void
onFailed: (message?: string) => void
}) => {
_installOnInstalled = onInstalled
_installOnFailed = onFailed
_installOnCancel = onCancel
_installOnStartToInstall = onStartToInstall ?? null
return (
<div data-testid="install-step">
<span data-testid="install-uid">{uniqueIdentifier}</span>
<span data-testid="install-payload-name">{payload.name}</span>
<button data-testid="install-cancel-btn" onClick={onCancel}>Cancel</button>
<button data-testid="install-start-btn" onClick={() => onStartToInstall?.()}>
Start Install
</button>
<button data-testid="install-installed-btn" onClick={() => onInstalled()}>
Installed
</button>
<button data-testid="install-installed-no-refresh-btn" onClick={() => onInstalled(true)}>
Installed (No Refresh)
</button>
<button data-testid="install-failed-btn" onClick={() => onFailed()}>
Failed
</button>
<button data-testid="install-failed-msg-btn" onClick={() => onFailed('Error message')}>
Failed with Message
</button>
</div>
)
},
}))
// Mock Installed component
vi.mock('../base/installed', () => ({
default: ({
payload,
isFailed,
errMsg,
onCancel,
}: {
payload: PluginDeclaration | null
isFailed: boolean
errMsg: string | null
onCancel: () => void
}) => (
<div data-testid="installed-step">
<span data-testid="installed-payload-name">{payload?.name || 'null'}</span>
<span data-testid="installed-is-failed">{isFailed ? 'true' : 'false'}</span>
<span data-testid="installed-err-msg">{errMsg || 'null'}</span>
<button data-testid="installed-cancel-btn" onClick={onCancel}>Close</button>
</div>
),
}))
describe('ReadyToInstall', () => {
const defaultProps = {
step: InstallStep.readyToInstall,
onStepChange: vi.fn(),
onStartToInstall: vi.fn(),
setIsInstalling: vi.fn(),
onClose: vi.fn(),
uniqueIdentifier: 'test-unique-identifier',
manifest: createMockManifest(),
errorMsg: null as string | null,
onError: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
_installOnInstalled = null
_installOnFailed = null
_installOnCancel = null
_installOnStartToInstall = null
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render Install component when step is readyToInstall', () => {
render(<ReadyToInstall {...defaultProps} step={InstallStep.readyToInstall} />)
expect(screen.getByTestId('install-step')).toBeInTheDocument()
expect(screen.queryByTestId('installed-step')).not.toBeInTheDocument()
})
it('should render Installed component when step is uploadFailed', () => {
render(<ReadyToInstall {...defaultProps} step={InstallStep.uploadFailed} />)
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
})
it('should render Installed component when step is installed', () => {
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} />)
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
})
it('should render Installed component when step is installFailed', () => {
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} />)
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
})
})
// ================================
// Props Passing Tests
// ================================
describe('Props Passing', () => {
it('should pass uniqueIdentifier to Install component', () => {
render(<ReadyToInstall {...defaultProps} uniqueIdentifier="custom-uid" />)
expect(screen.getByTestId('install-uid')).toHaveTextContent('custom-uid')
})
it('should pass manifest to Install component', () => {
const manifest = createMockManifest({ name: 'Custom Plugin' })
render(<ReadyToInstall {...defaultProps} manifest={manifest} />)
expect(screen.getByTestId('install-payload-name')).toHaveTextContent('Custom Plugin')
})
it('should pass manifest to Installed component', () => {
const manifest = createMockManifest({ name: 'Installed Plugin' })
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} manifest={manifest} />)
expect(screen.getByTestId('installed-payload-name')).toHaveTextContent('Installed Plugin')
})
it('should pass errorMsg to Installed component', () => {
render(
<ReadyToInstall
{...defaultProps}
step={InstallStep.installFailed}
errorMsg="Some error"
/>,
)
expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('Some error')
})
it('should pass isFailed=true for uploadFailed step', () => {
render(<ReadyToInstall {...defaultProps} step={InstallStep.uploadFailed} />)
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true')
})
it('should pass isFailed=true for installFailed step', () => {
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} />)
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true')
})
it('should pass isFailed=false for installed step', () => {
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} />)
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('false')
})
})
// ================================
// handleInstalled Callback Tests
// ================================
describe('handleInstalled Callback', () => {
it('should call onStepChange with installed when handleInstalled is triggered', () => {
const onStepChange = vi.fn()
render(<ReadyToInstall {...defaultProps} onStepChange={onStepChange} />)
fireEvent.click(screen.getByTestId('install-installed-btn'))
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed)
})
it('should call refreshPluginList when handleInstalled is triggered without notRefresh', () => {
const manifest = createMockManifest()
render(<ReadyToInstall {...defaultProps} manifest={manifest} />)
fireEvent.click(screen.getByTestId('install-installed-btn'))
expect(mockRefreshPluginList).toHaveBeenCalledWith(manifest)
})
it('should not call refreshPluginList when handleInstalled is triggered with notRefresh=true', () => {
render(<ReadyToInstall {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-installed-no-refresh-btn'))
expect(mockRefreshPluginList).not.toHaveBeenCalled()
})
it('should call setIsInstalling(false) when handleInstalled is triggered', () => {
const setIsInstalling = vi.fn()
render(<ReadyToInstall {...defaultProps} setIsInstalling={setIsInstalling} />)
fireEvent.click(screen.getByTestId('install-installed-btn'))
expect(setIsInstalling).toHaveBeenCalledWith(false)
})
})
// ================================
// handleFailed Callback Tests
// ================================
describe('handleFailed Callback', () => {
it('should call onStepChange with installFailed when handleFailed is triggered', () => {
const onStepChange = vi.fn()
render(<ReadyToInstall {...defaultProps} onStepChange={onStepChange} />)
fireEvent.click(screen.getByTestId('install-failed-btn'))
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed)
})
it('should call setIsInstalling(false) when handleFailed is triggered', () => {
const setIsInstalling = vi.fn()
render(<ReadyToInstall {...defaultProps} setIsInstalling={setIsInstalling} />)
fireEvent.click(screen.getByTestId('install-failed-btn'))
expect(setIsInstalling).toHaveBeenCalledWith(false)
})
it('should call onError when handleFailed is triggered with error message', () => {
const onError = vi.fn()
render(<ReadyToInstall {...defaultProps} onError={onError} />)
fireEvent.click(screen.getByTestId('install-failed-msg-btn'))
expect(onError).toHaveBeenCalledWith('Error message')
})
it('should not call onError when handleFailed is triggered without error message', () => {
const onError = vi.fn()
render(<ReadyToInstall {...defaultProps} onError={onError} />)
fireEvent.click(screen.getByTestId('install-failed-btn'))
expect(onError).not.toHaveBeenCalled()
})
})
// ================================
// onClose Callback Tests
// ================================
describe('onClose Callback', () => {
it('should call onClose when cancel is clicked in Install component', () => {
const onClose = vi.fn()
render(<ReadyToInstall {...defaultProps} onClose={onClose} />)
fireEvent.click(screen.getByTestId('install-cancel-btn'))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when cancel is clicked in Installed component', () => {
const onClose = vi.fn()
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} onClose={onClose} />)
fireEvent.click(screen.getByTestId('installed-cancel-btn'))
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// ================================
// onStartToInstall Callback Tests
// ================================
describe('onStartToInstall Callback', () => {
it('should pass onStartToInstall to Install component', () => {
const onStartToInstall = vi.fn()
render(<ReadyToInstall {...defaultProps} onStartToInstall={onStartToInstall} />)
fireEvent.click(screen.getByTestId('install-start-btn'))
expect(onStartToInstall).toHaveBeenCalledTimes(1)
})
})
// ================================
// Step Transitions Tests
// ================================
describe('Step Transitions', () => {
it('should handle transition from readyToInstall to installed', () => {
const onStepChange = vi.fn()
const { rerender } = render(
<ReadyToInstall {...defaultProps} step={InstallStep.readyToInstall} onStepChange={onStepChange} />,
)
// Initially shows Install component
expect(screen.getByTestId('install-step')).toBeInTheDocument()
// Simulate successful installation
fireEvent.click(screen.getByTestId('install-installed-btn'))
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed)
// Rerender with new step
rerender(<ReadyToInstall {...defaultProps} step={InstallStep.installed} onStepChange={onStepChange} />)
// Now shows Installed component
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
})
it('should handle transition from readyToInstall to installFailed', () => {
const onStepChange = vi.fn()
const { rerender } = render(
<ReadyToInstall {...defaultProps} step={InstallStep.readyToInstall} onStepChange={onStepChange} />,
)
// Initially shows Install component
expect(screen.getByTestId('install-step')).toBeInTheDocument()
// Simulate failed installation
fireEvent.click(screen.getByTestId('install-failed-btn'))
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed)
// Rerender with new step
rerender(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} onStepChange={onStepChange} />)
// Now shows Installed component with failed state
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
expect(screen.getByTestId('installed-is-failed')).toHaveTextContent('true')
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle null manifest', () => {
render(<ReadyToInstall {...defaultProps} step={InstallStep.installed} manifest={null} />)
expect(screen.getByTestId('installed-payload-name')).toHaveTextContent('null')
})
it('should handle null errorMsg', () => {
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} errorMsg={null} />)
expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('null')
})
it('should handle empty string errorMsg', () => {
render(<ReadyToInstall {...defaultProps} step={InstallStep.installFailed} errorMsg="" />)
expect(screen.getByTestId('installed-err-msg')).toHaveTextContent('null')
})
})
// ================================
// Callback Stability Tests
// ================================
describe('Callback Stability', () => {
it('should maintain stable handleInstalled callback across re-renders', () => {
const onStepChange = vi.fn()
const setIsInstalling = vi.fn()
const { rerender } = render(
<ReadyToInstall
{...defaultProps}
onStepChange={onStepChange}
setIsInstalling={setIsInstalling}
/>,
)
// Rerender with same props
rerender(
<ReadyToInstall
{...defaultProps}
onStepChange={onStepChange}
setIsInstalling={setIsInstalling}
/>,
)
// Callback should still work
fireEvent.click(screen.getByTestId('install-installed-btn'))
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installed)
expect(setIsInstalling).toHaveBeenCalledWith(false)
})
it('should maintain stable handleFailed callback across re-renders', () => {
const onStepChange = vi.fn()
const setIsInstalling = vi.fn()
const onError = vi.fn()
const { rerender } = render(
<ReadyToInstall
{...defaultProps}
onStepChange={onStepChange}
setIsInstalling={setIsInstalling}
onError={onError}
/>,
)
// Rerender with same props
rerender(
<ReadyToInstall
{...defaultProps}
onStepChange={onStepChange}
setIsInstalling={setIsInstalling}
onError={onError}
/>,
)
// Callback should still work
fireEvent.click(screen.getByTestId('install-failed-msg-btn'))
expect(onStepChange).toHaveBeenCalledWith(InstallStep.installFailed)
expect(setIsInstalling).toHaveBeenCalledWith(false)
expect(onError).toHaveBeenCalledWith('Error message')
})
})
})

View File

@ -0,0 +1,626 @@
import type { PluginDeclaration } from '../../../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum, TaskStatus } from '../../../types'
import Install from './install'
// Factory function for test data
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
plugin_unique_identifier: 'test-plugin-uid',
version: '1.0.0',
author: 'test-author',
icon: 'test-icon.png',
name: 'Test Plugin',
category: PluginCategoryEnum.tool,
label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'],
description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'],
created_at: '2024-01-01T00:00:00Z',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0', minimum_dify_version: '0.8.0' },
trigger: {} as PluginDeclaration['trigger'],
...overrides,
})
// Mock external dependencies
const mockUseCheckInstalled = vi.fn()
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => mockUseCheckInstalled(),
}))
const mockInstallPackageFromLocal = vi.fn()
vi.mock('@/service/use-plugins', () => ({
useInstallPackageFromLocal: () => ({
mutateAsync: mockInstallPackageFromLocal,
}),
usePluginTaskList: () => ({
handleRefetch: vi.fn(),
}),
}))
const mockUninstallPlugin = vi.fn()
vi.mock('@/service/plugins', () => ({
uninstallPlugin: (...args: unknown[]) => mockUninstallPlugin(...args),
}))
const mockCheck = vi.fn()
const mockStop = vi.fn()
vi.mock('../../base/check-task-status', () => ({
default: () => ({
check: mockCheck,
stop: mockStop,
}),
}))
const mockLangGeniusVersionInfo = { current_version: '1.0.0' }
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
langGeniusVersionInfo: mockLangGeniusVersionInfo,
}),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string } & Record<string, unknown>) => {
// Build full key with namespace prefix if provided
const fullKey = options?.ns ? `${options.ns}.${key}` : key
// Handle interpolation params (excluding ns)
const { ns: _ns, ...params } = options || {}
if (Object.keys(params).length > 0) {
return `${fullKey}:${JSON.stringify(params)}`
}
return fullKey
},
}),
Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => (
<span data-testid="trans">
{i18nKey}
{components?.trustSource}
</span>
),
}))
vi.mock('../../../card', () => ({
default: ({ payload, titleLeft }: {
payload: Record<string, unknown>
titleLeft?: React.ReactNode
}) => (
<div data-testid="card">
<span data-testid="card-name">{payload?.name as string}</span>
<div data-testid="card-title-left">{titleLeft}</div>
</div>
),
}))
vi.mock('../../base/version', () => ({
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
hasInstalled: boolean
installedVersion?: string
toInstallVersion: string
}) => (
<div data-testid="version">
<span data-testid="version-has-installed">{hasInstalled ? 'true' : 'false'}</span>
<span data-testid="version-installed">{installedVersion || 'null'}</span>
<span data-testid="version-to-install">{toInstallVersion}</span>
</div>
),
}))
vi.mock('../../utils', () => ({
pluginManifestToCardPluginProps: (manifest: PluginDeclaration) => ({
name: manifest.name,
author: manifest.author,
version: manifest.version,
}),
}))
describe('Install', () => {
const defaultProps = {
uniqueIdentifier: 'test-unique-identifier',
payload: createMockManifest(),
onCancel: vi.fn(),
onStartToInstall: vi.fn(),
onInstalled: vi.fn(),
onFailed: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockUseCheckInstalled.mockReturnValue({
installedInfo: null,
isLoading: false,
})
mockInstallPackageFromLocal.mockReset()
mockUninstallPlugin.mockReset()
mockCheck.mockReset()
mockStop.mockReset()
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render ready to install message', () => {
render(<Install {...defaultProps} />)
expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument()
})
it('should render trust source message', () => {
render(<Install {...defaultProps} />)
expect(screen.getByTestId('trans')).toBeInTheDocument()
})
it('should render plugin card', () => {
render(<Install {...defaultProps} />)
expect(screen.getByTestId('card')).toBeInTheDocument()
expect(screen.getByTestId('card-name')).toHaveTextContent('Test Plugin')
})
it('should render cancel button', () => {
render(<Install {...defaultProps} />)
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
it('should render install button', () => {
render(<Install {...defaultProps} />)
expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).toBeInTheDocument()
})
it('should show version component when not loading', () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: null,
isLoading: false,
})
render(<Install {...defaultProps} />)
expect(screen.getByTestId('version')).toBeInTheDocument()
})
it('should not show version component when loading', () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: null,
isLoading: true,
})
render(<Install {...defaultProps} />)
expect(screen.queryByTestId('version')).not.toBeInTheDocument()
})
})
// ================================
// Version Display Tests
// ================================
describe('Version Display', () => {
it('should display toInstallVersion from payload', () => {
const payload = createMockManifest({ version: '2.0.0' })
mockUseCheckInstalled.mockReturnValue({
installedInfo: null,
isLoading: false,
})
render(<Install {...defaultProps} payload={payload} />)
expect(screen.getByTestId('version-to-install')).toHaveTextContent('2.0.0')
})
it('should display hasInstalled=false when not installed', () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: null,
isLoading: false,
})
render(<Install {...defaultProps} />)
expect(screen.getByTestId('version-has-installed')).toHaveTextContent('false')
})
it('should display hasInstalled=true when already installed', () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: {
'test-author/Test Plugin': {
installedVersion: '0.9.0',
installedId: 'installed-id',
uniqueIdentifier: 'old-uid',
},
},
isLoading: false,
})
render(<Install {...defaultProps} />)
expect(screen.getByTestId('version-has-installed')).toHaveTextContent('true')
expect(screen.getByTestId('version-installed')).toHaveTextContent('0.9.0')
})
})
// ================================
// Install Button State Tests
// ================================
describe('Install Button State', () => {
it('should disable install button when loading', () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: null,
isLoading: true,
})
render(<Install {...defaultProps} />)
expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).toBeDisabled()
})
it('should enable install button when not loading', () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: null,
isLoading: false,
})
render(<Install {...defaultProps} />)
expect(screen.getByRole('button', { name: 'plugin.installModal.install' })).not.toBeDisabled()
})
})
// ================================
// Cancel Button Tests
// ================================
describe('Cancel Button', () => {
it('should call onCancel and stop when cancel button is clicked', () => {
const onCancel = vi.fn()
render(<Install {...defaultProps} onCancel={onCancel} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(mockStop).toHaveBeenCalled()
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should hide cancel button when installing', async () => {
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
render(<Install {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument()
})
})
})
// ================================
// Installation Flow Tests
// ================================
describe('Installation Flow', () => {
it('should call onStartToInstall when install button is clicked', async () => {
mockInstallPackageFromLocal.mockResolvedValue({
all_installed: true,
task_id: 'task-123',
})
const onStartToInstall = vi.fn()
render(<Install {...defaultProps} onStartToInstall={onStartToInstall} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
expect(onStartToInstall).toHaveBeenCalledTimes(1)
})
})
it('should call onInstalled when all_installed is true', async () => {
mockInstallPackageFromLocal.mockResolvedValue({
all_installed: true,
task_id: 'task-123',
})
const onInstalled = vi.fn()
render(<Install {...defaultProps} onInstalled={onInstalled} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
expect(onInstalled).toHaveBeenCalled()
})
})
it('should check task status when all_installed is false', async () => {
mockInstallPackageFromLocal.mockResolvedValue({
all_installed: false,
task_id: 'task-123',
})
mockCheck.mockResolvedValue({ status: TaskStatus.success, error: null })
const onInstalled = vi.fn()
render(<Install {...defaultProps} onInstalled={onInstalled} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
expect(mockCheck).toHaveBeenCalledWith({
taskId: 'task-123',
pluginUniqueIdentifier: 'test-unique-identifier',
})
})
await waitFor(() => {
expect(onInstalled).toHaveBeenCalledWith(true)
})
})
it('should call onFailed when task status is failed', async () => {
mockInstallPackageFromLocal.mockResolvedValue({
all_installed: false,
task_id: 'task-123',
})
mockCheck.mockResolvedValue({ status: TaskStatus.failed, error: 'Task failed error' })
const onFailed = vi.fn()
render(<Install {...defaultProps} onFailed={onFailed} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith('Task failed error')
})
})
it('should uninstall existing plugin before installing new version', async () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: {
'test-author/Test Plugin': {
installedVersion: '0.9.0',
installedId: 'installed-id-to-uninstall',
uniqueIdentifier: 'old-uid',
},
},
isLoading: false,
})
mockUninstallPlugin.mockResolvedValue({})
mockInstallPackageFromLocal.mockResolvedValue({
all_installed: true,
task_id: 'task-123',
})
render(<Install {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
expect(mockUninstallPlugin).toHaveBeenCalledWith('installed-id-to-uninstall')
})
await waitFor(() => {
expect(mockInstallPackageFromLocal).toHaveBeenCalled()
})
})
})
// ================================
// Error Handling Tests
// ================================
describe('Error Handling', () => {
it('should call onFailed with error string', async () => {
mockInstallPackageFromLocal.mockRejectedValue('Installation error string')
const onFailed = vi.fn()
render(<Install {...defaultProps} onFailed={onFailed} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith('Installation error string')
})
})
it('should call onFailed without message when error is not string', async () => {
mockInstallPackageFromLocal.mockRejectedValue({ code: 'ERROR' })
const onFailed = vi.fn()
render(<Install {...defaultProps} onFailed={onFailed} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith()
})
})
})
// ================================
// Auto Install Behavior Tests
// ================================
describe('Auto Install Behavior', () => {
it('should call onInstalled when already installed with same uniqueIdentifier', async () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: {
'test-author/Test Plugin': {
installedVersion: '1.0.0',
installedId: 'installed-id',
uniqueIdentifier: 'test-unique-identifier',
},
},
isLoading: false,
})
const onInstalled = vi.fn()
render(<Install {...defaultProps} onInstalled={onInstalled} />)
await waitFor(() => {
expect(onInstalled).toHaveBeenCalled()
})
})
it('should not auto-call onInstalled when uniqueIdentifier differs', () => {
mockUseCheckInstalled.mockReturnValue({
installedInfo: {
'test-author/Test Plugin': {
installedVersion: '1.0.0',
installedId: 'installed-id',
uniqueIdentifier: 'different-uid',
},
},
isLoading: false,
})
const onInstalled = vi.fn()
render(<Install {...defaultProps} onInstalled={onInstalled} />)
// Should not be called immediately
expect(onInstalled).not.toHaveBeenCalled()
})
})
// ================================
// Dify Version Compatibility Tests
// ================================
describe('Dify Version Compatibility', () => {
it('should not show warning when dify version is compatible', () => {
mockLangGeniusVersionInfo.current_version = '1.0.0'
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '0.8.0' } })
render(<Install {...defaultProps} payload={payload} />)
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
})
it('should show warning when dify version is incompatible', () => {
mockLangGeniusVersionInfo.current_version = '1.0.0'
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
render(<Install {...defaultProps} payload={payload} />)
expect(screen.getByText(/plugin.difyVersionNotCompatible/)).toBeInTheDocument()
})
it('should be compatible when minimum_dify_version is undefined', () => {
mockLangGeniusVersionInfo.current_version = '1.0.0'
const payload = createMockManifest({ meta: { version: '1.0.0' } })
render(<Install {...defaultProps} payload={payload} />)
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
})
it('should be compatible when current_version is empty', () => {
mockLangGeniusVersionInfo.current_version = ''
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
render(<Install {...defaultProps} payload={payload} />)
// When current_version is empty, should be compatible (no warning)
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
})
it('should be compatible when current_version is undefined', () => {
mockLangGeniusVersionInfo.current_version = undefined as unknown as string
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
render(<Install {...defaultProps} payload={payload} />)
// When current_version is undefined, should be compatible (no warning)
expect(screen.queryByText(/plugin.difyVersionNotCompatible/)).not.toBeInTheDocument()
})
})
// ================================
// Installing State Tests
// ================================
describe('Installing State', () => {
it('should show installing text when installing', async () => {
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
render(<Install {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument()
})
})
it('should disable install button when installing', async () => {
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
render(<Install {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /plugin.installModal.installing/ })).toBeDisabled()
})
})
it('should show loading spinner when installing', async () => {
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
render(<Install {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
const spinner = document.querySelector('.animate-spin-slow')
expect(spinner).toBeInTheDocument()
})
})
it('should not trigger install twice when already installing', async () => {
mockInstallPackageFromLocal.mockImplementation(() => new Promise(() => {}))
render(<Install {...defaultProps} />)
const installButton = screen.getByRole('button', { name: 'plugin.installModal.install' })
// Click install
fireEvent.click(installButton)
await waitFor(() => {
expect(mockInstallPackageFromLocal).toHaveBeenCalledTimes(1)
})
// Try to click again (button should be disabled but let's verify the guard works)
fireEvent.click(screen.getByRole('button', { name: /plugin.installModal.installing/ }))
// Should still only be called once due to isInstalling guard
expect(mockInstallPackageFromLocal).toHaveBeenCalledTimes(1)
})
})
// ================================
// Callback Props Tests
// ================================
describe('Callback Props', () => {
it('should work without onStartToInstall callback', async () => {
mockInstallPackageFromLocal.mockResolvedValue({
all_installed: true,
task_id: 'task-123',
})
const onInstalled = vi.fn()
render(
<Install
{...defaultProps}
onStartToInstall={undefined}
onInstalled={onInstalled}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'plugin.installModal.install' }))
await waitFor(() => {
expect(onInstalled).toHaveBeenCalled()
})
})
})
})

View File

@ -0,0 +1,356 @@
import type { Dependency, PluginDeclaration } from '../../../types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../types'
import Uploading from './uploading'
// Factory function for test data
const createMockManifest = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
plugin_unique_identifier: 'test-plugin-uid',
version: '1.0.0',
author: 'test-author',
icon: 'test-icon.png',
name: 'Test Plugin',
category: PluginCategoryEnum.tool,
label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'],
description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'],
created_at: '2024-01-01T00:00:00Z',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {} as PluginDeclaration['trigger'],
...overrides,
})
const createMockDependencies = (): Dependency[] => [
{
type: 'package',
value: {
unique_identifier: 'dep-1',
manifest: createMockManifest({ name: 'Dep Plugin 1' }),
},
},
]
const createMockFile = (name: string = 'test-plugin.difypkg'): File => {
return new File(['test content'], name, { type: 'application/octet-stream' })
}
// Mock external dependencies
const mockUploadFile = vi.fn()
vi.mock('@/service/plugins', () => ({
uploadFile: (...args: unknown[]) => mockUploadFile(...args),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string } & Record<string, unknown>) => {
// Build full key with namespace prefix if provided
const fullKey = options?.ns ? `${options.ns}.${key}` : key
// Handle interpolation params (excluding ns)
const { ns: _ns, ...params } = options || {}
if (Object.keys(params).length > 0) {
return `${fullKey}:${JSON.stringify(params)}`
}
return fullKey
},
}),
}))
vi.mock('../../../card', () => ({
default: ({ payload, isLoading, loadingFileName }: {
payload: { name: string }
isLoading?: boolean
loadingFileName?: string
}) => (
<div data-testid="card">
<span data-testid="card-name">{payload?.name}</span>
<span data-testid="card-is-loading">{isLoading ? 'true' : 'false'}</span>
<span data-testid="card-loading-filename">{loadingFileName || 'null'}</span>
</div>
),
}))
describe('Uploading', () => {
const defaultProps = {
isBundle: false,
file: createMockFile(),
onCancel: vi.fn(),
onPackageUploaded: vi.fn(),
onBundleUploaded: vi.fn(),
onFailed: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockUploadFile.mockReset()
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render uploading message with file name', () => {
render(<Uploading {...defaultProps} />)
expect(screen.getByText(/plugin.installModal.uploadingPackage/)).toBeInTheDocument()
})
it('should render loading spinner', () => {
render(<Uploading {...defaultProps} />)
// The spinner has animate-spin-slow class
const spinner = document.querySelector('.animate-spin-slow')
expect(spinner).toBeInTheDocument()
})
it('should render card with loading state', () => {
render(<Uploading {...defaultProps} />)
expect(screen.getByTestId('card-is-loading')).toHaveTextContent('true')
})
it('should render card with file name', () => {
const file = createMockFile('my-plugin.difypkg')
render(<Uploading {...defaultProps} file={file} />)
expect(screen.getByTestId('card-name')).toHaveTextContent('my-plugin.difypkg')
expect(screen.getByTestId('card-loading-filename')).toHaveTextContent('my-plugin.difypkg')
})
it('should render cancel button', () => {
render(<Uploading {...defaultProps} />)
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
it('should render disabled install button', () => {
render(<Uploading {...defaultProps} />)
const installButton = screen.getByRole('button', { name: 'plugin.installModal.install' })
expect(installButton).toBeDisabled()
})
})
// ================================
// Upload Behavior Tests
// ================================
describe('Upload Behavior', () => {
it('should call uploadFile on mount', async () => {
mockUploadFile.mockResolvedValue({})
render(<Uploading {...defaultProps} />)
await waitFor(() => {
expect(mockUploadFile).toHaveBeenCalledWith(defaultProps.file, false)
})
})
it('should call uploadFile with isBundle=true for bundle files', async () => {
mockUploadFile.mockResolvedValue({})
render(<Uploading {...defaultProps} isBundle />)
await waitFor(() => {
expect(mockUploadFile).toHaveBeenCalledWith(defaultProps.file, true)
})
})
it('should call onFailed when upload fails with error message', async () => {
const errorMessage = 'Upload failed: file too large'
mockUploadFile.mockRejectedValue({
response: { message: errorMessage },
})
const onFailed = vi.fn()
render(<Uploading {...defaultProps} onFailed={onFailed} />)
await waitFor(() => {
expect(onFailed).toHaveBeenCalledWith(errorMessage)
})
})
// NOTE: The uploadFile API has an unconventional contract where it always rejects.
// Success vs failure is determined by whether response.message exists:
// - If response.message exists → treated as failure (calls onFailed)
// - If response.message is absent → treated as success (calls onPackageUploaded/onBundleUploaded)
// This explains why we use mockRejectedValue for "success" scenarios below.
it('should call onPackageUploaded when upload rejects without error message (success case)', async () => {
const mockResult = {
unique_identifier: 'test-uid',
manifest: createMockManifest(),
}
mockUploadFile.mockRejectedValue({
response: mockResult,
})
const onPackageUploaded = vi.fn()
render(
<Uploading
{...defaultProps}
isBundle={false}
onPackageUploaded={onPackageUploaded}
/>,
)
await waitFor(() => {
expect(onPackageUploaded).toHaveBeenCalledWith({
uniqueIdentifier: mockResult.unique_identifier,
manifest: mockResult.manifest,
})
})
})
it('should call onBundleUploaded when upload rejects without error message (success case)', async () => {
const mockDependencies = createMockDependencies()
mockUploadFile.mockRejectedValue({
response: mockDependencies,
})
const onBundleUploaded = vi.fn()
render(
<Uploading
{...defaultProps}
isBundle
onBundleUploaded={onBundleUploaded}
/>,
)
await waitFor(() => {
expect(onBundleUploaded).toHaveBeenCalledWith(mockDependencies)
})
})
})
// ================================
// Cancel Button Tests
// ================================
describe('Cancel Button', () => {
it('should call onCancel when cancel button is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(<Uploading {...defaultProps} onCancel={onCancel} />)
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
// ================================
// File Name Display Tests
// ================================
describe('File Name Display', () => {
it('should display correct file name for package file', () => {
const file = createMockFile('custom-plugin.difypkg')
render(<Uploading {...defaultProps} file={file} />)
expect(screen.getByTestId('card-name')).toHaveTextContent('custom-plugin.difypkg')
})
it('should display correct file name for bundle file', () => {
const file = createMockFile('custom-bundle.difybndl')
render(<Uploading {...defaultProps} file={file} isBundle />)
expect(screen.getByTestId('card-name')).toHaveTextContent('custom-bundle.difybndl')
})
it('should display file name in uploading message', () => {
const file = createMockFile('special-plugin.difypkg')
render(<Uploading {...defaultProps} file={file} />)
// The message includes the file name as a parameter
expect(screen.getByText(/plugin\.installModal\.uploadingPackage/)).toHaveTextContent('special-plugin.difypkg')
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle empty response gracefully', async () => {
mockUploadFile.mockRejectedValue({
response: {},
})
const onPackageUploaded = vi.fn()
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />)
await waitFor(() => {
expect(onPackageUploaded).toHaveBeenCalledWith({
uniqueIdentifier: undefined,
manifest: undefined,
})
})
})
it('should handle response with only unique_identifier', async () => {
mockUploadFile.mockRejectedValue({
response: { unique_identifier: 'only-uid' },
})
const onPackageUploaded = vi.fn()
render(<Uploading {...defaultProps} onPackageUploaded={onPackageUploaded} />)
await waitFor(() => {
expect(onPackageUploaded).toHaveBeenCalledWith({
uniqueIdentifier: 'only-uid',
manifest: undefined,
})
})
})
it('should handle file with special characters in name', () => {
const file = createMockFile('my plugin (v1.0).difypkg')
render(<Uploading {...defaultProps} file={file} />)
expect(screen.getByTestId('card-name')).toHaveTextContent('my plugin (v1.0).difypkg')
})
})
// ================================
// Props Variations Tests
// ================================
describe('Props Variations', () => {
it('should work with different file types', () => {
const files = [
createMockFile('plugin-a.difypkg'),
createMockFile('plugin-b.zip'),
createMockFile('bundle.difybndl'),
]
files.forEach((file) => {
const { unmount } = render(<Uploading {...defaultProps} file={file} />)
expect(screen.getByTestId('card-name')).toHaveTextContent(file.name)
unmount()
})
})
it('should pass isBundle=false to uploadFile for package files', async () => {
mockUploadFile.mockResolvedValue({})
render(<Uploading {...defaultProps} isBundle={false} />)
await waitFor(() => {
expect(mockUploadFile).toHaveBeenCalledWith(expect.anything(), false)
})
})
it('should pass isBundle=true to uploadFile for bundle files', async () => {
mockUploadFile.mockResolvedValue({})
render(<Uploading {...defaultProps} isBundle />)
await waitFor(() => {
expect(mockUploadFile).toHaveBeenCalledWith(expect.anything(), true)
})
})
})
})

View File

@ -0,0 +1,928 @@
import type { Dependency, Plugin, PluginManifestInMarket } from '../../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InstallStep, PluginCategoryEnum } from '../../types'
import InstallFromMarketplace from './index'
// Factory functions for test data
// Use type casting to avoid strict locale requirements in tests
const createMockManifest = (overrides: Partial<PluginManifestInMarket> = {}): PluginManifestInMarket => ({
plugin_unique_identifier: 'test-unique-identifier',
name: 'Test Plugin',
org: 'test-org',
icon: 'test-icon.png',
label: { en_US: 'Test Plugin' } as PluginManifestInMarket['label'],
category: PluginCategoryEnum.tool,
version: '1.0.0',
latest_version: '1.0.0',
brief: { en_US: 'A test plugin' } as PluginManifestInMarket['brief'],
introduction: 'Introduction text',
verified: true,
install_count: 100,
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'test-org',
name: 'Test Plugin',
plugin_id: 'test-plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'test-package-id',
icon: 'test-icon.png',
verified: true,
label: { en_US: 'Test Plugin' },
brief: { en_US: 'A test plugin' },
description: { en_US: 'A test plugin description' },
introduction: 'Introduction text',
repository: 'https://github.com/test/plugin',
category: PluginCategoryEnum.tool,
install_count: 100,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const createMockDependencies = (): Dependency[] => [
{
type: 'github',
value: {
repo: 'test/plugin1',
version: 'v1.0.0',
package: 'plugin1.zip',
},
},
{
type: 'marketplace',
value: {
plugin_unique_identifier: 'plugin-2-uid',
},
},
]
// Mock external dependencies
const mockRefreshPluginList = vi.fn()
vi.mock('../hooks/use-refresh-plugin-list', () => ({
default: () => ({ refreshPluginList: mockRefreshPluginList }),
}))
let mockHideLogicState = {
modalClassName: 'test-modal-class',
foldAnimInto: vi.fn(),
setIsInstalling: vi.fn(),
handleStartToInstall: vi.fn(),
}
vi.mock('../hooks/use-hide-logic', () => ({
default: () => mockHideLogicState,
}))
// Mock child components
vi.mock('./steps/install', () => ({
default: ({
uniqueIdentifier,
payload,
onCancel,
onInstalled,
onFailed,
onStartToInstall,
}: {
uniqueIdentifier: string
payload: PluginManifestInMarket | Plugin
onCancel: () => void
onInstalled: (notRefresh?: boolean) => void
onFailed: (message?: string) => void
onStartToInstall: () => void
}) => (
<div data-testid="install-step">
<span data-testid="unique-identifier">{uniqueIdentifier}</span>
<span data-testid="payload-name">{payload?.name}</span>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
<button data-testid="start-install-btn" onClick={onStartToInstall}>Start Install</button>
<button data-testid="install-success-btn" onClick={() => onInstalled()}>Install Success</button>
<button data-testid="install-success-no-refresh-btn" onClick={() => onInstalled(true)}>Install Success No Refresh</button>
<button data-testid="install-fail-btn" onClick={() => onFailed('Installation failed')}>Install Fail</button>
<button data-testid="install-fail-no-msg-btn" onClick={() => onFailed()}>Install Fail No Msg</button>
</div>
),
}))
vi.mock('../install-bundle/ready-to-install', () => ({
default: ({
step,
onStepChange,
onStartToInstall,
setIsInstalling,
onClose,
allPlugins,
isFromMarketPlace,
}: {
step: InstallStep
onStepChange: (step: InstallStep) => void
onStartToInstall: () => void
setIsInstalling: (isInstalling: boolean) => void
onClose: () => void
allPlugins: Dependency[]
isFromMarketPlace?: boolean
}) => (
<div data-testid="bundle-step">
<span data-testid="bundle-step-value">{step}</span>
<span data-testid="bundle-plugins-count">{allPlugins?.length || 0}</span>
<span data-testid="is-from-marketplace">{isFromMarketPlace ? 'true' : 'false'}</span>
<button data-testid="bundle-cancel-btn" onClick={onClose}>Cancel</button>
<button data-testid="bundle-start-install-btn" onClick={onStartToInstall}>Start Install</button>
<button data-testid="bundle-set-installing-true" onClick={() => setIsInstalling(true)}>Set Installing True</button>
<button data-testid="bundle-set-installing-false" onClick={() => setIsInstalling(false)}>Set Installing False</button>
<button data-testid="bundle-change-to-installed" onClick={() => onStepChange(InstallStep.installed)}>Change to Installed</button>
<button data-testid="bundle-change-to-failed" onClick={() => onStepChange(InstallStep.installFailed)}>Change to Failed</button>
</div>
),
}))
vi.mock('../base/installed', () => ({
default: ({
payload,
isMarketPayload,
isFailed,
errMsg,
onCancel,
}: {
payload: PluginManifestInMarket | Plugin | null
isMarketPayload?: boolean
isFailed: boolean
errMsg?: string | null
onCancel: () => void
}) => (
<div data-testid="installed-step">
<span data-testid="installed-payload">{payload?.name || 'no-payload'}</span>
<span data-testid="is-market-payload">{isMarketPayload ? 'true' : 'false'}</span>
<span data-testid="is-failed">{isFailed ? 'true' : 'false'}</span>
<span data-testid="error-msg">{errMsg || 'no-error'}</span>
<button data-testid="installed-close-btn" onClick={onCancel}>Close</button>
</div>
),
}))
describe('InstallFromMarketplace', () => {
const defaultProps = {
uniqueIdentifier: 'test-unique-identifier',
manifest: createMockManifest(),
onSuccess: vi.fn(),
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockHideLogicState = {
modalClassName: 'test-modal-class',
foldAnimInto: vi.fn(),
setIsInstalling: vi.fn(),
handleStartToInstall: vi.fn(),
}
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render modal with correct initial state for single plugin', () => {
render(<InstallFromMarketplace {...defaultProps} />)
expect(screen.getByTestId('install-step')).toBeInTheDocument()
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
})
it('should render with bundle step when isBundle is true', () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
expect(screen.getByTestId('bundle-step')).toBeInTheDocument()
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2')
})
it('should pass isFromMarketPlace as true to bundle component', () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
expect(screen.getByTestId('is-from-marketplace')).toHaveTextContent('true')
})
it('should pass correct props to Install component', () => {
render(<InstallFromMarketplace {...defaultProps} />)
expect(screen.getByTestId('unique-identifier')).toHaveTextContent('test-unique-identifier')
expect(screen.getByTestId('payload-name')).toHaveTextContent('Test Plugin')
})
it('should apply modal className from useHideLogic', () => {
expect(mockHideLogicState.modalClassName).toBe('test-modal-class')
})
})
// ================================
// Title Display Tests
// ================================
describe('Title Display', () => {
it('should show install title in readyToInstall step', () => {
render(<InstallFromMarketplace {...defaultProps} />)
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
})
it('should show success title when installation completes for single plugin', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installedSuccessfully')).toBeInTheDocument()
})
})
it('should show bundle complete title when bundle installation completes', async () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
})
})
it('should show failed title when installation fails', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-fail-btn'))
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument()
})
})
})
// ================================
// State Management Tests
// ================================
describe('State Management', () => {
it('should transition from readyToInstall to installed on success', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
expect(screen.getByTestId('install-step')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
expect(screen.getByTestId('is-failed')).toHaveTextContent('false')
})
})
it('should transition from readyToInstall to installFailed on failure', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-fail-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
expect(screen.getByTestId('error-msg')).toHaveTextContent('Installation failed')
})
})
it('should handle failure without error message', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-fail-no-msg-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
expect(screen.getByTestId('error-msg')).toHaveTextContent('no-error')
})
})
it('should update step via onStepChange in bundle mode', async () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
})
})
})
// ================================
// Callback Stability Tests (Memoization)
// ================================
describe('Callback Stability', () => {
it('should maintain stable getTitle callback across rerenders', () => {
const { rerender } = render(<InstallFromMarketplace {...defaultProps} />)
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
rerender(<InstallFromMarketplace {...defaultProps} />)
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
})
it('should maintain stable handleInstalled callback', async () => {
const { rerender } = render(<InstallFromMarketplace {...defaultProps} />)
rerender(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
})
})
it('should maintain stable handleFailed callback', async () => {
const { rerender } = render(<InstallFromMarketplace {...defaultProps} />)
rerender(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-fail-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
})
})
})
// ================================
// User Interactions Tests
// ================================
describe('User Interactions', () => {
it('should call onClose when cancel is clicked', () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('cancel-btn'))
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('should call foldAnimInto when modal close is triggered', () => {
render(<InstallFromMarketplace {...defaultProps} />)
expect(mockHideLogicState.foldAnimInto).toBeDefined()
})
it('should call handleStartToInstall when start install is triggered', () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('start-install-btn'))
expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalledTimes(1)
})
it('should call onSuccess when close button is clicked in installed step', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('installed-close-btn'))
expect(defaultProps.onSuccess).toHaveBeenCalledTimes(1)
})
it('should call onClose in bundle mode cancel', () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
fireEvent.click(screen.getByTestId('bundle-cancel-btn'))
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
})
// ================================
// Refresh Plugin List Tests
// ================================
describe('Refresh Plugin List', () => {
it('should call refreshPluginList when installation completes without notRefresh flag', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(mockRefreshPluginList).toHaveBeenCalledWith(defaultProps.manifest)
})
})
it('should not call refreshPluginList when notRefresh flag is true', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-no-refresh-btn'))
await waitFor(() => {
expect(mockRefreshPluginList).not.toHaveBeenCalled()
})
})
})
// ================================
// setIsInstalling Tests
// ================================
describe('setIsInstalling Behavior', () => {
it('should call setIsInstalling(false) when installation completes', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
})
})
it('should call setIsInstalling(false) when installation fails', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-fail-btn'))
await waitFor(() => {
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
})
})
it('should pass setIsInstalling to bundle component', () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
fireEvent.click(screen.getByTestId('bundle-set-installing-true'))
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByTestId('bundle-set-installing-false'))
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
})
})
// ================================
// Installed Component Props Tests
// ================================
describe('Installed Component Props', () => {
it('should pass isMarketPayload as true to Installed component', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.getByTestId('is-market-payload')).toHaveTextContent('true')
})
})
it('should pass correct payload to Installed component', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-payload')).toHaveTextContent('Test Plugin')
})
})
it('should pass isFailed as true when installation fails', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-fail-btn'))
await waitFor(() => {
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
})
})
it('should pass error message to Installed component on failure', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-fail-btn'))
await waitFor(() => {
expect(screen.getByTestId('error-msg')).toHaveTextContent('Installation failed')
})
})
})
// ================================
// Prop Variations Tests
// ================================
describe('Prop Variations', () => {
it('should work with Plugin type manifest', () => {
const plugin = createMockPlugin()
render(
<InstallFromMarketplace
{...defaultProps}
manifest={plugin}
/>,
)
expect(screen.getByTestId('payload-name')).toHaveTextContent('Test Plugin')
})
it('should work with PluginManifestInMarket type manifest', () => {
const manifest = createMockManifest({ name: 'Market Plugin' })
render(
<InstallFromMarketplace
{...defaultProps}
manifest={manifest}
/>,
)
expect(screen.getByTestId('payload-name')).toHaveTextContent('Market Plugin')
})
it('should handle different uniqueIdentifier values', () => {
render(
<InstallFromMarketplace
{...defaultProps}
uniqueIdentifier="custom-unique-id-123"
/>,
)
expect(screen.getByTestId('unique-identifier')).toHaveTextContent('custom-unique-id-123')
})
it('should work without isBundle prop (default to single plugin)', () => {
render(<InstallFromMarketplace {...defaultProps} />)
expect(screen.getByTestId('install-step')).toBeInTheDocument()
expect(screen.queryByTestId('bundle-step')).not.toBeInTheDocument()
})
it('should work with isBundle=false', () => {
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={false}
/>,
)
expect(screen.getByTestId('install-step')).toBeInTheDocument()
expect(screen.queryByTestId('bundle-step')).not.toBeInTheDocument()
})
it('should work with empty dependencies array in bundle mode', () => {
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={[]}
/>,
)
expect(screen.getByTestId('bundle-step')).toBeInTheDocument()
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('0')
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle manifest with minimal required fields', () => {
const minimalManifest = createMockManifest({
name: 'Minimal',
version: '0.0.1',
})
render(
<InstallFromMarketplace
{...defaultProps}
manifest={minimalManifest}
/>,
)
expect(screen.getByTestId('payload-name')).toHaveTextContent('Minimal')
})
it('should handle multiple rapid state transitions', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
// Trigger installation completion
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
})
// Should stay in installed state
expect(screen.getByTestId('is-failed')).toHaveTextContent('false')
})
it('should handle bundle mode step changes', async () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
// Change to installed step
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
})
})
it('should handle bundle mode failure step change', async () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
fireEvent.click(screen.getByTestId('bundle-change-to-failed'))
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument()
})
})
it('should not render Install component in terminal steps', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
})
})
it('should render Installed component for success state with isFailed false', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
expect(screen.getByTestId('is-failed')).toHaveTextContent('false')
})
})
it('should render Installed component for failure state with isFailed true', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-fail-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
})
})
})
// ================================
// Terminal Steps Rendering Tests
// ================================
describe('Terminal Steps Rendering', () => {
it('should render Installed component when step is installed', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
})
})
it('should render Installed component when step is installFailed', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-fail-btn'))
await waitFor(() => {
expect(screen.getByTestId('installed-step')).toBeInTheDocument()
expect(screen.getByTestId('is-failed')).toHaveTextContent('true')
})
})
it('should not render Install component when in terminal step', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
// Initially Install is shown
expect(screen.getByTestId('install-step')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.queryByTestId('install-step')).not.toBeInTheDocument()
})
})
})
// ================================
// Data Flow Tests
// ================================
describe('Data Flow', () => {
it('should pass uniqueIdentifier to Install component', () => {
render(<InstallFromMarketplace {...defaultProps} uniqueIdentifier="flow-test-id" />)
expect(screen.getByTestId('unique-identifier')).toHaveTextContent('flow-test-id')
})
it('should pass manifest payload to Install component', () => {
const customManifest = createMockManifest({ name: 'Flow Test Plugin' })
render(<InstallFromMarketplace {...defaultProps} manifest={customManifest} />)
expect(screen.getByTestId('payload-name')).toHaveTextContent('Flow Test Plugin')
})
it('should pass dependencies to bundle component', () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2')
})
it('should pass current step to bundle component', () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
expect(screen.getByTestId('bundle-step-value')).toHaveTextContent(InstallStep.readyToInstall)
})
})
// ================================
// Manifest Category Variations Tests
// ================================
describe('Manifest Category Variations', () => {
it('should handle tool category manifest', () => {
const manifest = createMockManifest({ category: PluginCategoryEnum.tool })
render(<InstallFromMarketplace {...defaultProps} manifest={manifest} />)
expect(screen.getByTestId('install-step')).toBeInTheDocument()
})
it('should handle model category manifest', () => {
const manifest = createMockManifest({ category: PluginCategoryEnum.model })
render(<InstallFromMarketplace {...defaultProps} manifest={manifest} />)
expect(screen.getByTestId('install-step')).toBeInTheDocument()
})
it('should handle extension category manifest', () => {
const manifest = createMockManifest({ category: PluginCategoryEnum.extension })
render(<InstallFromMarketplace {...defaultProps} manifest={manifest} />)
expect(screen.getByTestId('install-step')).toBeInTheDocument()
})
})
// ================================
// Hook Integration Tests
// ================================
describe('Hook Integration', () => {
it('should use handleStartToInstall from useHideLogic', () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('start-install-btn'))
expect(mockHideLogicState.handleStartToInstall).toHaveBeenCalled()
})
it('should use setIsInstalling from useHideLogic in handleInstalled', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
})
})
it('should use setIsInstalling from useHideLogic in handleFailed', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-fail-btn'))
await waitFor(() => {
expect(mockHideLogicState.setIsInstalling).toHaveBeenCalledWith(false)
})
})
it('should use refreshPluginList from useRefreshPluginList', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(mockRefreshPluginList).toHaveBeenCalled()
})
})
})
// ================================
// getTitle Memoization Tests
// ================================
describe('getTitle Memoization', () => {
it('should return installPlugin title for readyToInstall step', () => {
render(<InstallFromMarketplace {...defaultProps} />)
expect(screen.getByText('plugin.installModal.installPlugin')).toBeInTheDocument()
})
it('should return installedSuccessfully for non-bundle installed step', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-success-btn'))
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installedSuccessfully')).toBeInTheDocument()
})
})
it('should return installComplete for bundle installed step', async () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
fireEvent.click(screen.getByTestId('bundle-change-to-installed'))
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installComplete')).toBeInTheDocument()
})
})
it('should return installFailed for installFailed step', async () => {
render(<InstallFromMarketplace {...defaultProps} />)
fireEvent.click(screen.getByTestId('install-fail-btn'))
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installFailed')).toBeInTheDocument()
})
})
})
})

View File

@ -0,0 +1,729 @@
import type { Plugin, PluginManifestInMarket } from '../../../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum, TaskStatus } from '../../../types'
import Install from './install'
// Factory functions for test data
const createMockManifest = (overrides: Partial<PluginManifestInMarket> = {}): PluginManifestInMarket => ({
plugin_unique_identifier: 'test-unique-identifier',
name: 'Test Plugin',
org: 'test-org',
icon: 'test-icon.png',
label: { en_US: 'Test Plugin' } as PluginManifestInMarket['label'],
category: PluginCategoryEnum.tool,
version: '1.0.0',
latest_version: '1.0.0',
brief: { en_US: 'A test plugin' } as PluginManifestInMarket['brief'],
introduction: 'Introduction text',
verified: true,
install_count: 100,
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'test-org',
name: 'Test Plugin',
plugin_id: 'test-plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'test-package-id',
icon: 'test-icon.png',
verified: true,
label: { en_US: 'Test Plugin' },
brief: { en_US: 'A test plugin' },
description: { en_US: 'A test plugin description' },
introduction: 'Introduction text',
repository: 'https://github.com/test/plugin',
category: PluginCategoryEnum.tool,
install_count: 100,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
// Mock variables for controlling test behavior
let mockInstalledInfo: Record<string, { installedId: string, installedVersion: string, uniqueIdentifier: string }> | undefined
let mockIsLoading = false
const mockInstallPackageFromMarketPlace = vi.fn()
const mockUpdatePackageFromMarketPlace = vi.fn()
const mockCheckTaskStatus = vi.fn()
const mockStopTaskStatus = vi.fn()
const mockHandleRefetch = vi.fn()
let mockPluginDeclaration: { manifest: { meta: { minimum_dify_version: string } } } | undefined
let mockCanInstall = true
let mockLangGeniusVersionInfo = { current_version: '1.0.0' }
// Mock useCheckInstalled
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: ({ pluginIds }: { pluginIds: string[], enabled: boolean }) => ({
installedInfo: mockInstalledInfo,
isLoading: mockIsLoading,
error: null,
}),
}))
// Mock service hooks
vi.mock('@/service/use-plugins', () => ({
useInstallPackageFromMarketPlace: () => ({
mutateAsync: mockInstallPackageFromMarketPlace,
}),
useUpdatePackageFromMarketPlace: () => ({
mutateAsync: mockUpdatePackageFromMarketPlace,
}),
usePluginDeclarationFromMarketPlace: () => ({
data: mockPluginDeclaration,
}),
usePluginTaskList: () => ({
handleRefetch: mockHandleRefetch,
}),
}))
// Mock checkTaskStatus
vi.mock('../../base/check-task-status', () => ({
default: () => ({
check: mockCheckTaskStatus,
stop: mockStopTaskStatus,
}),
}))
// Mock useAppContext
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
langGeniusVersionInfo: mockLangGeniusVersionInfo,
}),
}))
// Mock useInstallPluginLimit
vi.mock('../../hooks/use-install-plugin-limit', () => ({
default: () => ({ canInstall: mockCanInstall }),
}))
// Mock Card component
vi.mock('../../../card', () => ({
default: ({ payload, titleLeft, className, limitedInstall }: {
payload: any
titleLeft?: React.ReactNode
className?: string
limitedInstall?: boolean
}) => (
<div data-testid="plugin-card">
<span data-testid="card-payload-name">{payload?.name}</span>
<span data-testid="card-limited-install">{limitedInstall ? 'true' : 'false'}</span>
{titleLeft && <div data-testid="card-title-left">{titleLeft}</div>}
</div>
),
}))
// Mock Version component
vi.mock('../../base/version', () => ({
default: ({ hasInstalled, installedVersion, toInstallVersion }: {
hasInstalled: boolean
installedVersion?: string
toInstallVersion: string
}) => (
<div data-testid="version-component">
<span data-testid="has-installed">{hasInstalled ? 'true' : 'false'}</span>
<span data-testid="installed-version">{installedVersion || 'none'}</span>
<span data-testid="to-install-version">{toInstallVersion}</span>
</div>
),
}))
// Mock utils
vi.mock('../../utils', () => ({
pluginManifestInMarketToPluginProps: (payload: PluginManifestInMarket) => ({
name: payload.name,
icon: payload.icon,
category: payload.category,
}),
}))
describe('Install Component (steps/install.tsx)', () => {
const defaultProps = {
uniqueIdentifier: 'test-unique-identifier',
payload: createMockManifest(),
onCancel: vi.fn(),
onStartToInstall: vi.fn(),
onInstalled: vi.fn(),
onFailed: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockInstalledInfo = undefined
mockIsLoading = false
mockPluginDeclaration = undefined
mockCanInstall = true
mockLangGeniusVersionInfo = { current_version: '1.0.0' }
mockInstallPackageFromMarketPlace.mockResolvedValue({
all_installed: false,
task_id: 'task-123',
})
mockUpdatePackageFromMarketPlace.mockResolvedValue({
all_installed: false,
task_id: 'task-456',
})
mockCheckTaskStatus.mockResolvedValue({
status: TaskStatus.success,
})
})
// ================================
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render ready to install text', () => {
render(<Install {...defaultProps} />)
expect(screen.getByText('plugin.installModal.readyToInstall')).toBeInTheDocument()
})
it('should render plugin card with correct payload', () => {
render(<Install {...defaultProps} />)
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Test Plugin')
})
it('should render cancel button when not installing', () => {
render(<Install {...defaultProps} />)
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
})
it('should render install button', () => {
render(<Install {...defaultProps} />)
expect(screen.getByText('plugin.installModal.install')).toBeInTheDocument()
})
it('should not render version component while loading', () => {
mockIsLoading = true
render(<Install {...defaultProps} />)
expect(screen.queryByTestId('version-component')).not.toBeInTheDocument()
})
it('should render version component when not loading', () => {
mockIsLoading = false
render(<Install {...defaultProps} />)
expect(screen.getByTestId('version-component')).toBeInTheDocument()
})
})
// ================================
// Version Display Tests
// ================================
describe('Version Display', () => {
it('should show hasInstalled as false when not installed', () => {
mockInstalledInfo = undefined
render(<Install {...defaultProps} />)
expect(screen.getByTestId('has-installed')).toHaveTextContent('false')
})
it('should show hasInstalled as true when already installed', () => {
mockInstalledInfo = {
'test-plugin-id': {
installedId: 'install-id',
installedVersion: '0.9.0',
uniqueIdentifier: 'old-unique-id',
},
}
const plugin = createMockPlugin()
render(<Install {...defaultProps} payload={plugin} />)
expect(screen.getByTestId('has-installed')).toHaveTextContent('true')
expect(screen.getByTestId('installed-version')).toHaveTextContent('0.9.0')
})
it('should show correct toInstallVersion from payload.version', () => {
const manifest = createMockManifest({ version: '2.0.0' })
render(<Install {...defaultProps} payload={manifest} />)
expect(screen.getByTestId('to-install-version')).toHaveTextContent('2.0.0')
})
it('should fallback to latest_version when version is undefined', () => {
const manifest = createMockManifest({ version: undefined as any, latest_version: '3.0.0' })
render(<Install {...defaultProps} payload={manifest} />)
expect(screen.getByTestId('to-install-version')).toHaveTextContent('3.0.0')
})
})
// ================================
// Version Compatibility Tests
// ================================
describe('Version Compatibility', () => {
it('should not show warning when no plugin declaration', () => {
mockPluginDeclaration = undefined
render(<Install {...defaultProps} />)
expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument()
})
it('should not show warning when dify version is compatible', () => {
mockLangGeniusVersionInfo = { current_version: '2.0.0' }
mockPluginDeclaration = {
manifest: { meta: { minimum_dify_version: '1.0.0' } },
}
render(<Install {...defaultProps} />)
expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument()
})
it('should show warning when dify version is incompatible', () => {
mockLangGeniusVersionInfo = { current_version: '1.0.0' }
mockPluginDeclaration = {
manifest: { meta: { minimum_dify_version: '2.0.0' } },
}
render(<Install {...defaultProps} />)
expect(screen.getByText(/plugin.difyVersionNotCompatible/)).toBeInTheDocument()
})
})
// ================================
// Install Limit Tests
// ================================
describe('Install Limit', () => {
it('should pass limitedInstall=false to Card when canInstall is true', () => {
mockCanInstall = true
render(<Install {...defaultProps} />)
expect(screen.getByTestId('card-limited-install')).toHaveTextContent('false')
})
it('should pass limitedInstall=true to Card when canInstall is false', () => {
mockCanInstall = false
render(<Install {...defaultProps} />)
expect(screen.getByTestId('card-limited-install')).toHaveTextContent('true')
})
it('should disable install button when canInstall is false', () => {
mockCanInstall = false
render(<Install {...defaultProps} />)
const installBtn = screen.getByText('plugin.installModal.install').closest('button')
expect(installBtn).toBeDisabled()
})
})
// ================================
// Button States Tests
// ================================
describe('Button States', () => {
it('should disable install button when loading', () => {
mockIsLoading = true
render(<Install {...defaultProps} />)
const installBtn = screen.getByText('plugin.installModal.install').closest('button')
expect(installBtn).toBeDisabled()
})
it('should enable install button when not loading and canInstall', () => {
mockIsLoading = false
mockCanInstall = true
render(<Install {...defaultProps} />)
const installBtn = screen.getByText('plugin.installModal.install').closest('button')
expect(installBtn).not.toBeDisabled()
})
})
// ================================
// Cancel Button Tests
// ================================
describe('Cancel Button', () => {
it('should call onCancel and stop when cancel is clicked', () => {
render(<Install {...defaultProps} />)
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(mockStopTaskStatus).toHaveBeenCalled()
expect(defaultProps.onCancel).toHaveBeenCalled()
})
})
// ================================
// New Installation Flow Tests
// ================================
describe('New Installation Flow', () => {
it('should call onStartToInstall when install button is clicked', async () => {
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
expect(defaultProps.onStartToInstall).toHaveBeenCalled()
})
it('should call installPackageFromMarketPlace for new installation', async () => {
mockInstalledInfo = undefined
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith('test-unique-identifier')
})
})
it('should call onInstalled immediately when all_installed is true', async () => {
mockInstallPackageFromMarketPlace.mockResolvedValue({
all_installed: true,
task_id: 'task-123',
})
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(defaultProps.onInstalled).toHaveBeenCalled()
expect(mockCheckTaskStatus).not.toHaveBeenCalled()
})
})
it('should check task status when all_installed is false', async () => {
mockInstallPackageFromMarketPlace.mockResolvedValue({
all_installed: false,
task_id: 'task-123',
})
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(mockHandleRefetch).toHaveBeenCalled()
expect(mockCheckTaskStatus).toHaveBeenCalledWith({
taskId: 'task-123',
pluginUniqueIdentifier: 'test-unique-identifier',
})
})
})
it('should call onInstalled with true when task succeeds', async () => {
mockCheckTaskStatus.mockResolvedValue({ status: TaskStatus.success })
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(defaultProps.onInstalled).toHaveBeenCalledWith(true)
})
})
it('should call onFailed when task fails', async () => {
mockCheckTaskStatus.mockResolvedValue({
status: TaskStatus.failed,
error: 'Task failed error',
})
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(defaultProps.onFailed).toHaveBeenCalledWith('Task failed error')
})
})
})
// ================================
// Update Installation Flow Tests
// ================================
describe('Update Installation Flow', () => {
beforeEach(() => {
mockInstalledInfo = {
'test-plugin-id': {
installedId: 'install-id',
installedVersion: '0.9.0',
uniqueIdentifier: 'old-unique-id',
},
}
})
it('should call updatePackageFromMarketPlace for update installation', async () => {
const plugin = createMockPlugin()
render(<Install {...defaultProps} payload={plugin} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(mockUpdatePackageFromMarketPlace).toHaveBeenCalledWith({
original_plugin_unique_identifier: 'old-unique-id',
new_plugin_unique_identifier: 'test-unique-identifier',
})
})
})
it('should not call installPackageFromMarketPlace when updating', async () => {
const plugin = createMockPlugin()
render(<Install {...defaultProps} payload={plugin} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(mockInstallPackageFromMarketPlace).not.toHaveBeenCalled()
})
})
})
// ================================
// Auto-Install on Already Installed Tests
// ================================
describe('Auto-Install on Already Installed', () => {
it('should call onInstalled when already installed with same uniqueIdentifier', async () => {
mockInstalledInfo = {
'test-plugin-id': {
installedId: 'install-id',
installedVersion: '1.0.0',
uniqueIdentifier: 'test-unique-identifier',
},
}
const plugin = createMockPlugin()
render(<Install {...defaultProps} payload={plugin} />)
await waitFor(() => {
expect(defaultProps.onInstalled).toHaveBeenCalled()
})
})
it('should not auto-install when uniqueIdentifier differs', async () => {
mockInstalledInfo = {
'test-plugin-id': {
installedId: 'install-id',
installedVersion: '1.0.0',
uniqueIdentifier: 'different-unique-id',
},
}
const plugin = createMockPlugin()
render(<Install {...defaultProps} payload={plugin} />)
// Wait a bit to ensure onInstalled is not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(defaultProps.onInstalled).not.toHaveBeenCalled()
})
})
// ================================
// Error Handling Tests
// ================================
describe('Error Handling', () => {
it('should call onFailed with string error', async () => {
mockInstallPackageFromMarketPlace.mockRejectedValue('String error message')
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(defaultProps.onFailed).toHaveBeenCalledWith('String error message')
})
})
it('should call onFailed without message for non-string error', async () => {
mockInstallPackageFromMarketPlace.mockRejectedValue(new Error('Error object'))
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(defaultProps.onFailed).toHaveBeenCalledWith()
})
})
})
// ================================
// Installing State Tests
// ================================
describe('Installing State', () => {
it('should hide cancel button while installing', async () => {
// Make the install take some time
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument()
})
})
it('should show installing text while installing', async () => {
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(screen.getByText('plugin.installModal.installing')).toBeInTheDocument()
})
})
it('should disable install button while installing', async () => {
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
const installBtn = screen.getByText('plugin.installModal.installing').closest('button')
expect(installBtn).toBeDisabled()
})
})
it('should not trigger multiple installs when clicking rapidly', async () => {
mockInstallPackageFromMarketPlace.mockImplementation(() => new Promise(() => {}))
render(<Install {...defaultProps} />)
const installBtn = screen.getByText('plugin.installModal.install').closest('button')!
await act(async () => {
fireEvent.click(installBtn)
})
// Wait for the button to be disabled
await waitFor(() => {
expect(installBtn).toBeDisabled()
})
// Try clicking again - should not trigger another install
await act(async () => {
fireEvent.click(installBtn)
fireEvent.click(installBtn)
})
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledTimes(1)
})
})
// ================================
// Prop Variations Tests
// ================================
describe('Prop Variations', () => {
it('should work with PluginManifestInMarket payload', () => {
const manifest = createMockManifest({ name: 'Manifest Plugin' })
render(<Install {...defaultProps} payload={manifest} />)
expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Manifest Plugin')
})
it('should work with Plugin payload', () => {
const plugin = createMockPlugin({ name: 'Plugin Type' })
render(<Install {...defaultProps} payload={plugin} />)
expect(screen.getByTestId('card-payload-name')).toHaveTextContent('Plugin Type')
})
it('should work without onStartToInstall callback', async () => {
const propsWithoutCallback = {
...defaultProps,
onStartToInstall: undefined,
}
render(<Install {...propsWithoutCallback} />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
// Should not throw and should proceed with installation
await waitFor(() => {
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalled()
})
})
it('should handle different uniqueIdentifier values', async () => {
render(<Install {...defaultProps} uniqueIdentifier="custom-id-123" />)
await act(async () => {
fireEvent.click(screen.getByText('plugin.installModal.install'))
})
await waitFor(() => {
expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith('custom-id-123')
})
})
})
// ================================
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle empty plugin_id gracefully', () => {
const manifest = createMockManifest()
// Manifest doesn't have plugin_id, so installedInfo won't match
render(<Install {...defaultProps} payload={manifest} />)
expect(screen.getByTestId('has-installed')).toHaveTextContent('false')
})
it('should handle undefined installedInfo', () => {
mockInstalledInfo = undefined
render(<Install {...defaultProps} />)
expect(screen.getByTestId('has-installed')).toHaveTextContent('false')
})
it('should handle null current_version in langGeniusVersionInfo', () => {
mockLangGeniusVersionInfo = { current_version: null as any }
mockPluginDeclaration = {
manifest: { meta: { minimum_dify_version: '1.0.0' } },
}
render(<Install {...defaultProps} />)
// Should not show warning when current_version is null (defaults to compatible)
expect(screen.queryByText(/difyVersionNotCompatible/)).not.toBeInTheDocument()
})
})
// ================================
// Component Memoization Tests
// ================================
describe('Component Memoization', () => {
it('should maintain stable component across rerenders with same props', () => {
const { rerender } = render(<Install {...defaultProps} />)
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
rerender(<Install {...defaultProps} />)
expect(screen.getByTestId('plugin-card')).toBeInTheDocument()
})
})
})