mirror of https://github.com/langgenius/dify.git
938 lines
28 KiB
TypeScript
938 lines
28 KiB
TypeScript
import type { MetaData, PluginCategoryEnum } from '../types'
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import Toast from '@/app/components/base/toast'
|
|
|
|
// ==================== Imports (after mocks) ====================
|
|
|
|
import { PluginSource } from '../types'
|
|
import Action from './action'
|
|
|
|
// ==================== Mock Setup ====================
|
|
|
|
// Use vi.hoisted to define mock functions that can be referenced in vi.mock
|
|
const {
|
|
mockUninstallPlugin,
|
|
mockFetchReleases,
|
|
mockCheckForUpdates,
|
|
mockSetShowUpdatePluginModal,
|
|
mockInvalidateInstalledPluginList,
|
|
} = vi.hoisted(() => ({
|
|
mockUninstallPlugin: vi.fn(),
|
|
mockFetchReleases: vi.fn(),
|
|
mockCheckForUpdates: vi.fn(),
|
|
mockSetShowUpdatePluginModal: vi.fn(),
|
|
mockInvalidateInstalledPluginList: vi.fn(),
|
|
}))
|
|
|
|
// Mock uninstall plugin service
|
|
vi.mock('@/service/plugins', () => ({
|
|
uninstallPlugin: (id: string) => mockUninstallPlugin(id),
|
|
}))
|
|
|
|
// Mock GitHub releases hook
|
|
vi.mock('../install-plugin/hooks', () => ({
|
|
useGitHubReleases: () => ({
|
|
fetchReleases: mockFetchReleases,
|
|
checkForUpdates: mockCheckForUpdates,
|
|
}),
|
|
}))
|
|
|
|
// Mock modal context
|
|
vi.mock('@/context/modal-context', () => ({
|
|
useModalContext: () => ({
|
|
setShowUpdatePluginModal: mockSetShowUpdatePluginModal,
|
|
}),
|
|
}))
|
|
|
|
// Mock invalidate installed plugin list
|
|
vi.mock('@/service/use-plugins', () => ({
|
|
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
|
|
}))
|
|
|
|
// Mock PluginInfo component - has complex dependencies (Modal, KeyValueItem)
|
|
vi.mock('../plugin-page/plugin-info', () => ({
|
|
default: ({ repository, release, packageName, onHide }: {
|
|
repository: string
|
|
release: string
|
|
packageName: string
|
|
onHide: () => void
|
|
}) => (
|
|
<div data-testid="plugin-info-modal" data-repo={repository} data-release={release} data-package={packageName}>
|
|
<button data-testid="close-plugin-info" onClick={onHide}>Close</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
// Mock Tooltip - uses PortalToFollowElem which requires complex floating UI setup
|
|
// Simplified mock that just renders children with tooltip content accessible
|
|
vi.mock('../../base/tooltip', () => ({
|
|
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
|
<div data-testid="tooltip" data-popup-content={popupContent}>
|
|
{children}
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
// Mock Confirm - uses createPortal which has issues in test environment
|
|
vi.mock('../../base/confirm', () => ({
|
|
default: ({ isShow, title, content, onCancel, onConfirm, isLoading, isDisabled }: {
|
|
isShow: boolean
|
|
title: string
|
|
content: React.ReactNode
|
|
onCancel: () => void
|
|
onConfirm: () => void
|
|
isLoading: boolean
|
|
isDisabled: boolean
|
|
}) => {
|
|
if (!isShow)
|
|
return null
|
|
return (
|
|
<div data-testid="confirm-modal" data-loading={isLoading} data-disabled={isDisabled}>
|
|
<div data-testid="confirm-title">{title}</div>
|
|
<div data-testid="confirm-content">{content}</div>
|
|
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
|
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isDisabled}>Confirm</button>
|
|
</div>
|
|
)
|
|
},
|
|
}))
|
|
|
|
// ==================== Test Utilities ====================
|
|
|
|
type ActionProps = {
|
|
author: string
|
|
installationId: string
|
|
pluginUniqueIdentifier: string
|
|
pluginName: string
|
|
category: PluginCategoryEnum
|
|
usedInApps: number
|
|
isShowFetchNewVersion: boolean
|
|
isShowInfo: boolean
|
|
isShowDelete: boolean
|
|
onDelete: () => void
|
|
meta?: MetaData
|
|
}
|
|
|
|
const createActionProps = (overrides: Partial<ActionProps> = {}): ActionProps => ({
|
|
author: 'test-author',
|
|
installationId: 'install-123',
|
|
pluginUniqueIdentifier: 'test-author/test-plugin@1.0.0',
|
|
pluginName: 'test-plugin',
|
|
category: 'tool' as PluginCategoryEnum,
|
|
usedInApps: 5,
|
|
isShowFetchNewVersion: false,
|
|
isShowInfo: false,
|
|
isShowDelete: true,
|
|
onDelete: vi.fn(),
|
|
meta: {
|
|
repo: 'test-author/test-plugin',
|
|
version: '1.0.0',
|
|
package: 'test-plugin.difypkg',
|
|
},
|
|
...overrides,
|
|
})
|
|
|
|
// ==================== Tests ====================
|
|
|
|
// Helper to find action buttons (real ActionButton component uses type="button")
|
|
const getActionButtons = () => screen.getAllByRole('button')
|
|
const queryActionButtons = () => screen.queryAllByRole('button')
|
|
|
|
describe('Action Component', () => {
|
|
// Spy on Toast.notify - real component but we track calls
|
|
let toastNotifySpy: ReturnType<typeof vi.spyOn>
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// Spy on Toast.notify and mock implementation to avoid DOM side effects
|
|
toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
|
mockUninstallPlugin.mockResolvedValue({ success: true })
|
|
mockFetchReleases.mockResolvedValue([])
|
|
mockCheckForUpdates.mockReturnValue({
|
|
needUpdate: false,
|
|
toastProps: { type: 'info', message: 'Up to date' },
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
toastNotifySpy.mockRestore()
|
|
})
|
|
|
|
// ==================== Rendering Tests ====================
|
|
describe('Rendering', () => {
|
|
it('should render delete button when isShowDelete is true', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
|
|
// Assert
|
|
expect(getActionButtons()).toHaveLength(1)
|
|
})
|
|
|
|
it('should render fetch new version button when isShowFetchNewVersion is true', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: true,
|
|
isShowInfo: false,
|
|
isShowDelete: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
|
|
// Assert
|
|
expect(getActionButtons()).toHaveLength(1)
|
|
})
|
|
|
|
it('should render info button when isShowInfo is true', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: false,
|
|
isShowInfo: true,
|
|
isShowDelete: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
|
|
// Assert
|
|
expect(getActionButtons()).toHaveLength(1)
|
|
})
|
|
|
|
it('should render all buttons when all flags are true', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: true,
|
|
isShowInfo: true,
|
|
isShowDelete: true,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
|
|
// Assert
|
|
expect(getActionButtons()).toHaveLength(3)
|
|
})
|
|
|
|
it('should render no buttons when all flags are false', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: false,
|
|
isShowInfo: false,
|
|
isShowDelete: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
|
|
// Assert
|
|
expect(queryActionButtons()).toHaveLength(0)
|
|
})
|
|
|
|
it('should render tooltips for each button', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: true,
|
|
isShowInfo: true,
|
|
isShowDelete: true,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
|
|
// Assert
|
|
const tooltips = screen.getAllByTestId('tooltip')
|
|
expect(tooltips).toHaveLength(3)
|
|
})
|
|
})
|
|
|
|
// ==================== Delete Functionality Tests ====================
|
|
describe('Delete Functionality', () => {
|
|
it('should show delete confirm modal when delete button is clicked', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
|
|
expect(screen.getByTestId('confirm-title')).toHaveTextContent('plugin.action.delete')
|
|
})
|
|
|
|
it('should display plugin name in delete confirm content', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
pluginName: 'my-awesome-plugin',
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Assert
|
|
expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should hide confirm modal when cancel is clicked', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByTestId('confirm-cancel'))
|
|
|
|
// Assert
|
|
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should call uninstallPlugin when confirm is clicked', async () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
installationId: 'install-456',
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-456')
|
|
})
|
|
})
|
|
|
|
it('should call onDelete callback after successful uninstall', async () => {
|
|
// Arrange
|
|
mockUninstallPlugin.mockResolvedValue({ success: true })
|
|
const onDelete = vi.fn()
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
onDelete,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(onDelete).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should not call onDelete if uninstall fails', async () => {
|
|
// Arrange
|
|
mockUninstallPlugin.mockResolvedValue({ success: false })
|
|
const onDelete = vi.fn()
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
onDelete,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockUninstallPlugin).toHaveBeenCalled()
|
|
})
|
|
expect(onDelete).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should handle uninstall error gracefully', async () => {
|
|
// Arrange
|
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
mockUninstallPlugin.mockRejectedValue(new Error('Network error'))
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(consoleError).toHaveBeenCalledWith('uninstallPlugin error', expect.any(Error))
|
|
})
|
|
|
|
consoleError.mockRestore()
|
|
})
|
|
|
|
it('should show loading state during deletion', async () => {
|
|
// Arrange
|
|
let resolveUninstall: (value: { success: boolean }) => void
|
|
mockUninstallPlugin.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveUninstall = resolve
|
|
}),
|
|
)
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
// Assert - Loading state
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true')
|
|
})
|
|
|
|
// Resolve and check modal closes
|
|
resolveUninstall!({ success: true })
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
// ==================== Plugin Info Tests ====================
|
|
describe('Plugin Info', () => {
|
|
it('should show plugin info modal when info button is clicked', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowInfo: true,
|
|
isShowDelete: false,
|
|
isShowFetchNewVersion: false,
|
|
meta: {
|
|
repo: 'owner/repo-name',
|
|
version: '2.0.0',
|
|
package: 'my-package.difypkg',
|
|
},
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument()
|
|
expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-repo', 'owner/repo-name')
|
|
expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-release', '2.0.0')
|
|
expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-package', 'my-package.difypkg')
|
|
})
|
|
|
|
it('should hide plugin info modal when close is clicked', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowInfo: true,
|
|
isShowDelete: false,
|
|
isShowFetchNewVersion: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByTestId('close-plugin-info'))
|
|
|
|
// Assert
|
|
expect(screen.queryByTestId('plugin-info-modal')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// ==================== Check for Updates Tests ====================
|
|
describe('Check for Updates', () => {
|
|
it('should fetch releases when check for updates button is clicked', async () => {
|
|
// Arrange
|
|
mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: true,
|
|
isShowDelete: false,
|
|
isShowInfo: false,
|
|
meta: {
|
|
repo: 'owner/repo',
|
|
version: '1.0.0',
|
|
package: 'pkg.difypkg',
|
|
},
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo')
|
|
})
|
|
})
|
|
|
|
it('should use author and pluginName as fallback for empty repo parts', async () => {
|
|
// Arrange
|
|
mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: true,
|
|
isShowDelete: false,
|
|
isShowInfo: false,
|
|
author: 'fallback-author',
|
|
pluginName: 'fallback-plugin',
|
|
meta: {
|
|
repo: '/', // Results in empty parts after split
|
|
version: '1.0.0',
|
|
package: 'pkg.difypkg',
|
|
},
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockFetchReleases).toHaveBeenCalledWith('fallback-author', 'fallback-plugin')
|
|
})
|
|
})
|
|
|
|
it('should not proceed if no releases are fetched', async () => {
|
|
// Arrange
|
|
mockFetchReleases.mockResolvedValue([])
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: true,
|
|
isShowDelete: false,
|
|
isShowInfo: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockFetchReleases).toHaveBeenCalled()
|
|
})
|
|
expect(mockCheckForUpdates).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should show toast notification after checking for updates', async () => {
|
|
// Arrange
|
|
mockFetchReleases.mockResolvedValue([{ version: '2.0.0' }])
|
|
mockCheckForUpdates.mockReturnValue({
|
|
needUpdate: false,
|
|
toastProps: { type: 'success', message: 'Already up to date' },
|
|
})
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: true,
|
|
isShowDelete: false,
|
|
isShowInfo: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Assert - Toast.notify is called with the toast props
|
|
await waitFor(() => {
|
|
expect(toastNotifySpy).toHaveBeenCalledWith({ type: 'success', message: 'Already up to date' })
|
|
})
|
|
})
|
|
|
|
it('should show update modal when update is available', async () => {
|
|
// Arrange
|
|
const releases = [{ version: '2.0.0' }]
|
|
mockFetchReleases.mockResolvedValue(releases)
|
|
mockCheckForUpdates.mockReturnValue({
|
|
needUpdate: true,
|
|
toastProps: { type: 'info', message: 'Update available' },
|
|
})
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: true,
|
|
isShowDelete: false,
|
|
isShowInfo: false,
|
|
pluginUniqueIdentifier: 'test-id',
|
|
category: 'model' as PluginCategoryEnum,
|
|
meta: {
|
|
repo: 'owner/repo',
|
|
version: '1.0.0',
|
|
package: 'pkg.difypkg',
|
|
},
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockSetShowUpdatePluginModal).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({
|
|
type: PluginSource.github,
|
|
category: 'model',
|
|
github: expect.objectContaining({
|
|
originalPackageInfo: expect.objectContaining({
|
|
id: 'test-id',
|
|
repo: 'owner/repo',
|
|
version: '1.0.0',
|
|
package: 'pkg.difypkg',
|
|
releases,
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
|
|
it('should call invalidateInstalledPluginList on save callback', async () => {
|
|
// Arrange
|
|
const releases = [{ version: '2.0.0' }]
|
|
mockFetchReleases.mockResolvedValue(releases)
|
|
mockCheckForUpdates.mockReturnValue({
|
|
needUpdate: true,
|
|
toastProps: { type: 'info', message: 'Update available' },
|
|
})
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: true,
|
|
isShowDelete: false,
|
|
isShowInfo: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Wait for modal to be called
|
|
await waitFor(() => {
|
|
expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
|
|
})
|
|
|
|
// Invoke the callback
|
|
const call = mockSetShowUpdatePluginModal.mock.calls[0][0]
|
|
call.onSaveCallback()
|
|
|
|
// Assert
|
|
expect(mockInvalidateInstalledPluginList).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should check updates with current version', async () => {
|
|
// Arrange
|
|
const releases = [{ version: '2.0.0' }, { version: '1.5.0' }]
|
|
mockFetchReleases.mockResolvedValue(releases)
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: true,
|
|
isShowDelete: false,
|
|
isShowInfo: false,
|
|
meta: {
|
|
repo: 'owner/repo',
|
|
version: '1.0.0',
|
|
package: 'pkg.difypkg',
|
|
},
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockCheckForUpdates).toHaveBeenCalledWith(releases, '1.0.0')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ==================== Callback Stability Tests ====================
|
|
describe('Callback Stability (useCallback)', () => {
|
|
it('should have stable handleDelete callback with same dependencies', async () => {
|
|
// Arrange
|
|
mockUninstallPlugin.mockResolvedValue({ success: true })
|
|
const onDelete = vi.fn()
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
onDelete,
|
|
installationId: 'stable-install-id',
|
|
})
|
|
|
|
// Act - First render and delete
|
|
const { rerender } = render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
await waitFor(() => {
|
|
expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id')
|
|
})
|
|
|
|
// Re-render with same props
|
|
mockUninstallPlugin.mockClear()
|
|
rerender(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
await waitFor(() => {
|
|
expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id')
|
|
})
|
|
})
|
|
|
|
it('should update handleDelete when installationId changes', async () => {
|
|
// Arrange
|
|
mockUninstallPlugin.mockResolvedValue({ success: true })
|
|
const props1 = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
installationId: 'install-1',
|
|
})
|
|
const props2 = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
installationId: 'install-2',
|
|
})
|
|
|
|
// Act
|
|
const { rerender } = render(<Action {...props1} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
await waitFor(() => {
|
|
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-1')
|
|
})
|
|
|
|
mockUninstallPlugin.mockClear()
|
|
rerender(<Action {...props2} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
await waitFor(() => {
|
|
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-2')
|
|
})
|
|
})
|
|
|
|
it('should update handleDelete when onDelete changes', async () => {
|
|
// Arrange
|
|
mockUninstallPlugin.mockResolvedValue({ success: true })
|
|
const onDelete1 = vi.fn()
|
|
const onDelete2 = vi.fn()
|
|
const props1 = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
onDelete: onDelete1,
|
|
})
|
|
const props2 = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
onDelete: onDelete2,
|
|
})
|
|
|
|
// Act
|
|
const { rerender } = render(<Action {...props1} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
await waitFor(() => {
|
|
expect(onDelete1).toHaveBeenCalled()
|
|
})
|
|
expect(onDelete2).not.toHaveBeenCalled()
|
|
|
|
rerender(<Action {...props2} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
await waitFor(() => {
|
|
expect(onDelete2).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
// ==================== Edge Cases ====================
|
|
describe('Edge Cases', () => {
|
|
it('should handle undefined meta for info display', () => {
|
|
// Arrange - meta is required for info, but test defensive behavior
|
|
const props = createActionProps({
|
|
isShowInfo: false,
|
|
isShowDelete: true,
|
|
isShowFetchNewVersion: false,
|
|
meta: undefined,
|
|
})
|
|
|
|
// Act & Assert - Should not crash
|
|
expect(() => render(<Action {...props} />)).not.toThrow()
|
|
})
|
|
|
|
it('should handle empty repo string', async () => {
|
|
// Arrange
|
|
mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
|
|
const props = createActionProps({
|
|
isShowFetchNewVersion: true,
|
|
isShowDelete: false,
|
|
isShowInfo: false,
|
|
author: 'fallback-owner',
|
|
pluginName: 'fallback-repo',
|
|
meta: {
|
|
repo: '',
|
|
version: '1.0.0',
|
|
package: 'pkg.difypkg',
|
|
},
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Assert - Should use author and pluginName as fallback
|
|
await waitFor(() => {
|
|
expect(mockFetchReleases).toHaveBeenCalledWith('fallback-owner', 'fallback-repo')
|
|
})
|
|
})
|
|
|
|
it('should handle concurrent delete requests gracefully', async () => {
|
|
// Arrange
|
|
let resolveFirst: (value: { success: boolean }) => void
|
|
const firstPromise = new Promise<{ success: boolean }>((resolve) => {
|
|
resolveFirst = resolve
|
|
})
|
|
mockUninstallPlugin.mockReturnValueOnce(firstPromise)
|
|
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
fireEvent.click(screen.getByTestId('confirm-ok'))
|
|
|
|
// The confirm button should be disabled during deletion
|
|
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true')
|
|
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-disabled', 'true')
|
|
|
|
// Resolve the deletion
|
|
resolveFirst!({ success: true })
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should handle special characters in plugin name', () => {
|
|
// Arrange
|
|
const props = createActionProps({
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
pluginName: 'plugin-with-special@chars#123',
|
|
})
|
|
|
|
// Act
|
|
render(<Action {...props} />)
|
|
fireEvent.click(getActionButtons()[0])
|
|
|
|
// Assert
|
|
expect(screen.getByText('plugin-with-special@chars#123')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// ==================== React.memo Tests ====================
|
|
describe('React.memo Behavior', () => {
|
|
it('should be wrapped with React.memo', () => {
|
|
// Assert
|
|
expect(Action).toBeDefined()
|
|
expect((Action as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
|
|
// ==================== Prop Variations ====================
|
|
describe('Prop Variations', () => {
|
|
it('should handle all category types', () => {
|
|
// Arrange
|
|
const categories = ['tool', 'model', 'extension', 'agent-strategy', 'datasource'] as PluginCategoryEnum[]
|
|
|
|
categories.forEach((category) => {
|
|
const props = createActionProps({
|
|
category,
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
})
|
|
expect(() => render(<Action {...props} />)).not.toThrow()
|
|
})
|
|
})
|
|
|
|
it('should handle different usedInApps values', () => {
|
|
// Arrange
|
|
const values = [0, 1, 5, 100]
|
|
|
|
values.forEach((usedInApps) => {
|
|
const props = createActionProps({
|
|
usedInApps,
|
|
isShowDelete: true,
|
|
isShowInfo: false,
|
|
isShowFetchNewVersion: false,
|
|
})
|
|
expect(() => render(<Action {...props} />)).not.toThrow()
|
|
})
|
|
})
|
|
|
|
it('should handle combination of multiple action buttons', () => {
|
|
// Arrange - Test various combinations
|
|
const combinations = [
|
|
{ isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: false },
|
|
{ isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: false },
|
|
{ isShowFetchNewVersion: false, isShowInfo: false, isShowDelete: true },
|
|
{ isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: false },
|
|
{ isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: true },
|
|
{ isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: true },
|
|
{ isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: true },
|
|
]
|
|
|
|
combinations.forEach((flags) => {
|
|
const props = createActionProps(flags)
|
|
const expectedCount = [flags.isShowFetchNewVersion, flags.isShowInfo, flags.isShowDelete].filter(Boolean).length
|
|
|
|
const { unmount } = render(<Action {...props} />)
|
|
const buttons = queryActionButtons()
|
|
expect(buttons).toHaveLength(expectedCount)
|
|
unmount()
|
|
})
|
|
})
|
|
})
|
|
})
|