dify/web/app/components/plugins/plugin-item/__tests__/action.spec.tsx
copilot-swe-agent[bot] 215d3ed42d
Merge remote-tracking branch 'origin/deploy/dev' into feat/evaluation
# Conflicts:
#	.vite-hooks/pre-commit
#	api/controllers/console/__init__.py
#	api/core/agent/base_agent_runner.py
#	api/core/app/app_config/easy_ui_based_app/model_config/converter.py
#	api/core/app/apps/agent_chat/app_runner.py
#	api/core/entities/provider_configuration.py
#	api/core/helper/moderation.py
#	api/core/model_manager.py
#	api/core/rag/embedding/cached_embedding.py
#	api/core/rag/retrieval/dataset_retrieval.py
#	api/core/rag/splitter/fixed_text_splitter.py
#	api/core/workflow/nodes/datasource/datasource_node.py
#	api/core/workflow/nodes/knowledge_index/knowledge_index_node.py
#	api/models/human_input.py
#	api/providers/trace/trace-tencent/src/dify_trace_tencent/span_builder.py
#	api/services/workflow_service.py
#	api/tasks/trigger_processing_tasks.py
#	api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py
#	api/tests/integration_tests/workflow/nodes/test_http.py
#	api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py
#	api/tests/unit_tests/controllers/service_api/app/test_conversation.py
#	api/tests/unit_tests/core/prompt/test_agent_history_prompt_transform.py
#	api/tests/unit_tests/core/variables/test_segment.py
#	api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py
#	api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py
#	api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py
#	api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py
#	api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py
#	api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py
#	web/app/(commonLayout)/layout.tsx
#	web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx
#	web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx
#	web/app/components/app/workflow-log/__tests__/list.spec.tsx
#	web/app/components/apps/__tests__/list.spec.tsx
#	web/app/components/apps/list.tsx
#	web/app/components/base/chat/chat-with-history/header/operation.tsx
#	web/app/components/base/chat/chat-with-history/sidebar/operation.tsx
#	web/app/components/header/account-setting/data-source-page-new/operator.tsx
#	web/app/components/header/account-setting/members-page/operation/index.tsx
#	web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx
#	web/app/components/plugins/marketplace/sort-dropdown/index.tsx
#	web/app/components/plugins/plugin-page/plugin-tasks/index.tsx
#	web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx
#	web/app/components/workflow/header/test-run-menu.tsx
#	web/app/components/workflow/nodes/_base/components/next-step/operator.tsx
#	web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx
#	web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx
#	web/app/components/workflow/nodes/assigner/components/operation-selector.tsx
#	web/app/components/workflow/operator/__tests__/more-actions.spec.tsx
#	web/app/components/workflow/operator/zoom-in-out.tsx
#	web/app/components/workflow/panel/version-history-panel/context-menu/menu-item.tsx
#	web/app/components/workflow/selection-contextmenu.tsx
#	web/eslint-suppressions.json

Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com>
2026-04-20 07:03:29 +00:00

926 lines
27 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'
// ==================== 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,
mockToastNotify,
} = vi.hoisted(() => ({
mockUninstallPlugin: vi.fn(),
mockFetchReleases: vi.fn(),
mockCheckForUpdates: vi.fn(),
mockSetShowUpdatePluginModal: vi.fn(),
mockInvalidateInstalledPluginList: vi.fn(),
mockToastNotify: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: Object.assign(
(message: string, options?: { type?: string }) => mockToastNotify({ type: options?.type, message }),
{
success: (message: string) => mockToastNotify({ type: 'success', message }),
error: (message: string) => mockToastNotify({ type: 'error', message }),
warning: (message: string) => mockToastNotify({ type: 'warning', message }),
info: (message: string) => mockToastNotify({ type: 'info', message }),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
},
),
}))
// Mock uninstall plugin service
vi.mock('@/service/plugins', () => ({
uninstallPlugin: (id: string) => mockUninstallPlugin(id),
}))
// Mock GitHub release helpers
vi.mock('../../install-plugin/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../install-plugin/hooks')>()
return {
...actual,
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>
),
}))
// ==================== 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,
})
const getDeleteConfirmButton = () => screen.getByRole('button', { name: /common\.operation\.confirm/ })
const getDeleteCancelButton = () => screen.getByRole('button', { name: 'common.operation.cancel' })
// ==================== 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', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUninstallPlugin.mockResolvedValue({ success: true })
mockFetchReleases.mockResolvedValue([])
mockCheckForUpdates.mockReturnValue({
needUpdate: false,
toastProps: { type: 'info', message: 'Up to date' },
})
})
// ==================== 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.getByText('plugin.action.delete')).toBeInTheDocument()
})
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.getByText('plugin.action.delete')).toBeInTheDocument()
fireEvent.click(getDeleteCancelButton())
// Assert
return waitFor(() => {
expect(screen.queryByText('plugin.action.delete')).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(getDeleteConfirmButton())
// 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(getDeleteConfirmButton())
// 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(getDeleteConfirmButton())
// 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(getDeleteConfirmButton())
// 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(getDeleteConfirmButton())
// Assert - Loading state
await waitFor(() => {
expect(getDeleteConfirmButton()).toBeDisabled()
})
// Resolve and check modal closes
resolveUninstall!({ success: true })
await waitFor(() => {
expect(screen.queryByText('plugin.action.delete')).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 is called with the translated payload
await waitFor(() => {
expect(mockToastNotify).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(getDeleteConfirmButton())
await waitFor(() => {
expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id')
})
// Re-render with same props
mockUninstallPlugin.mockClear()
rerender(<Action {...props} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(getDeleteConfirmButton())
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(getDeleteConfirmButton())
await waitFor(() => {
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-1')
})
mockUninstallPlugin.mockClear()
rerender(<Action {...props2} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(getDeleteConfirmButton())
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(getDeleteConfirmButton())
await waitFor(() => {
expect(onDelete1).toHaveBeenCalled()
})
expect(onDelete2).not.toHaveBeenCalled()
rerender(<Action {...props2} />)
fireEvent.click(getActionButtons()[0])
fireEvent.click(getDeleteConfirmButton())
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(getDeleteConfirmButton())
// The confirm button should be disabled during deletion
expect(getDeleteConfirmButton()).toBeDisabled()
// Resolve the deletion
resolveFirst!({ success: true })
await waitFor(() => {
expect(screen.queryByText('plugin.action.delete')).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 { $$typeof?: symbol }).$$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()
})
})
})
})