mirror of https://github.com/langgenius/dify.git
1237 lines
37 KiB
TypeScript
1237 lines
37 KiB
TypeScript
import type {
|
|
PluginDeclaration,
|
|
UpdateFromGitHubPayload,
|
|
UpdateFromMarketPlacePayload,
|
|
UpdatePluginModalType,
|
|
} from '../types'
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import * as React from 'react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { PluginCategoryEnum, PluginSource, TaskStatus } from '../types'
|
|
import DowngradeWarningModal from './downgrade-warning'
|
|
import FromGitHub from './from-github'
|
|
import UpdateFromMarketplace from './from-market-place'
|
|
import UpdatePlugin from './index'
|
|
import PluginVersionPicker from './plugin-version-picker'
|
|
|
|
// ================================
|
|
// Mock External Dependencies Only
|
|
// ================================
|
|
|
|
// Mock react-i18next
|
|
vi.mock('react-i18next', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('react-i18next')>()
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({
|
|
t: (key: string, options?: { ns?: string }) => {
|
|
const translations: Record<string, string> = {
|
|
'upgrade.title': 'Update Plugin',
|
|
'upgrade.successfulTitle': 'Plugin Updated',
|
|
'upgrade.description': 'This plugin will be updated to the new version.',
|
|
'upgrade.upgrade': 'Update',
|
|
'upgrade.upgrading': 'Updating...',
|
|
'upgrade.close': 'Close',
|
|
'operation.cancel': 'Cancel',
|
|
'newApp.Cancel': 'Cancel',
|
|
'autoUpdate.pluginDowngradeWarning.title': 'Downgrade Warning',
|
|
'autoUpdate.pluginDowngradeWarning.description': 'You are about to downgrade this plugin.',
|
|
'autoUpdate.pluginDowngradeWarning.downgrade': 'Just Downgrade',
|
|
'autoUpdate.pluginDowngradeWarning.exclude': 'Exclude and Downgrade',
|
|
'detailPanel.switchVersion': 'Switch Version',
|
|
}
|
|
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
|
return translations[fullKey] || translations[key] || key
|
|
},
|
|
}),
|
|
}
|
|
})
|
|
|
|
// Mock useGetLanguage context
|
|
vi.mock('@/context/i18n', () => ({
|
|
useGetLanguage: () => 'en-US',
|
|
}))
|
|
|
|
// Mock app context for useGetIcon
|
|
vi.mock('@/context/app-context', () => ({
|
|
useSelector: () => ({ id: 'test-workspace-id' }),
|
|
}))
|
|
|
|
// Mock hooks/use-timestamp
|
|
vi.mock('@/hooks/use-timestamp', () => ({
|
|
default: () => ({
|
|
formatDate: (timestamp: number, _format: string) => {
|
|
const date = new Date(timestamp * 1000)
|
|
return date.toISOString().split('T')[0]
|
|
},
|
|
}),
|
|
}))
|
|
|
|
// Mock plugins service
|
|
const mockUpdateFromMarketPlace = vi.fn()
|
|
vi.mock('@/service/plugins', () => ({
|
|
updateFromMarketPlace: (params: unknown) => mockUpdateFromMarketPlace(params),
|
|
checkTaskStatus: vi.fn().mockResolvedValue({
|
|
task: {
|
|
plugins: [{ plugin_unique_identifier: 'test-target-id', status: 'success' }],
|
|
},
|
|
}),
|
|
}))
|
|
|
|
// Mock use-plugins hooks
|
|
const mockHandleRefetch = vi.fn()
|
|
const mockMutateAsync = vi.fn()
|
|
const mockInvalidateReferenceSettings = vi.fn()
|
|
|
|
vi.mock('@/service/use-plugins', () => ({
|
|
usePluginTaskList: () => ({
|
|
handleRefetch: mockHandleRefetch,
|
|
}),
|
|
useRemoveAutoUpgrade: () => ({
|
|
mutateAsync: mockMutateAsync,
|
|
}),
|
|
useInvalidateReferenceSettings: () => mockInvalidateReferenceSettings,
|
|
useVersionListOfPlugin: () => ({
|
|
data: {
|
|
data: {
|
|
versions: [
|
|
{ version: '1.0.0', unique_identifier: 'plugin-v1.0.0', created_at: 1700000000 },
|
|
{ version: '1.1.0', unique_identifier: 'plugin-v1.1.0', created_at: 1700100000 },
|
|
{ version: '2.0.0', unique_identifier: 'plugin-v2.0.0', created_at: 1700200000 },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
}))
|
|
|
|
// Mock checkTaskStatus
|
|
const mockCheck = vi.fn()
|
|
const mockStop = vi.fn()
|
|
vi.mock('../install-plugin/base/check-task-status', () => ({
|
|
default: () => ({
|
|
check: mockCheck,
|
|
stop: mockStop,
|
|
}),
|
|
}))
|
|
|
|
// Mock Toast
|
|
vi.mock('../../base/toast', () => ({
|
|
default: {
|
|
notify: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
// Mock InstallFromGitHub component
|
|
vi.mock('../install-plugin/install-from-github', () => ({
|
|
default: ({ updatePayload, onClose, onSuccess }: {
|
|
updatePayload: UpdateFromGitHubPayload
|
|
onClose: () => void
|
|
onSuccess: () => void
|
|
}) => (
|
|
<div data-testid="install-from-github">
|
|
<span data-testid="github-payload">{JSON.stringify(updatePayload)}</span>
|
|
<button data-testid="github-close" onClick={onClose}>Close</button>
|
|
<button data-testid="github-success" onClick={onSuccess}>Success</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
// Mock Portal components for PluginVersionPicker
|
|
let mockPortalOpen = false
|
|
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: {
|
|
children: React.ReactNode
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
}) => {
|
|
mockPortalOpen = open
|
|
return <div data-testid="portal-elem" data-open={open}>{children}</div>
|
|
},
|
|
PortalToFollowElemTrigger: ({ children, onClick, className }: {
|
|
children: React.ReactNode
|
|
onClick: () => void
|
|
className?: string
|
|
}) => (
|
|
<div data-testid="portal-trigger" onClick={onClick} className={className}>
|
|
{children}
|
|
</div>
|
|
),
|
|
PortalToFollowElemContent: ({ children, className }: {
|
|
children: React.ReactNode
|
|
className?: string
|
|
}) => {
|
|
if (!mockPortalOpen)
|
|
return null
|
|
return <div data-testid="portal-content" className={className}>{children}</div>
|
|
},
|
|
}))
|
|
|
|
// Mock semver
|
|
vi.mock('semver', () => ({
|
|
lt: (v1: string, v2: string) => {
|
|
const parseVersion = (v: string) => v.split('.').map(Number)
|
|
const [major1, minor1, patch1] = parseVersion(v1)
|
|
const [major2, minor2, patch2] = parseVersion(v2)
|
|
if (major1 !== major2)
|
|
return major1 < major2
|
|
if (minor1 !== minor2)
|
|
return minor1 < minor2
|
|
return patch1 < patch2
|
|
},
|
|
}))
|
|
|
|
// ================================
|
|
// Test Data Factories
|
|
// ================================
|
|
|
|
const createMockPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
|
plugin_unique_identifier: 'test-plugin-id',
|
|
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-01',
|
|
resource: {},
|
|
plugins: {},
|
|
verified: true,
|
|
endpoint: { settings: [], endpoints: [] },
|
|
model: {},
|
|
tags: [],
|
|
agent_strategy: {},
|
|
meta: { version: '1.0.0' },
|
|
trigger: {
|
|
events: [],
|
|
identity: {
|
|
author: 'test',
|
|
name: 'test',
|
|
label: { 'en-US': 'Test' } as PluginDeclaration['label'],
|
|
description: { 'en-US': 'Test' } as PluginDeclaration['description'],
|
|
icon: 'test.png',
|
|
tags: [],
|
|
},
|
|
subscription_constructor: {
|
|
credentials_schema: [],
|
|
oauth_schema: { client_schema: [], credentials_schema: [] },
|
|
parameters: [],
|
|
},
|
|
subscription_schema: [],
|
|
},
|
|
...overrides,
|
|
})
|
|
|
|
const createMockMarketPlacePayload = (overrides: Partial<UpdateFromMarketPlacePayload> = {}): UpdateFromMarketPlacePayload => ({
|
|
category: PluginCategoryEnum.tool,
|
|
originalPackageInfo: {
|
|
id: 'original-id',
|
|
payload: createMockPluginDeclaration(),
|
|
},
|
|
targetPackageInfo: {
|
|
id: 'test-target-id',
|
|
version: '2.0.0',
|
|
},
|
|
...overrides,
|
|
})
|
|
|
|
const createMockGitHubPayload = (overrides: Partial<UpdateFromGitHubPayload> = {}): UpdateFromGitHubPayload => ({
|
|
originalPackageInfo: {
|
|
id: 'github-original-id',
|
|
repo: 'owner/repo',
|
|
version: '1.0.0',
|
|
package: 'test-package.difypkg',
|
|
releases: [
|
|
{ tag_name: 'v1.0.0', assets: [{ id: 1, name: 'plugin.difypkg', browser_download_url: 'https://github.com/test' }] },
|
|
{ tag_name: 'v2.0.0', assets: [{ id: 2, name: 'plugin.difypkg', browser_download_url: 'https://github.com/test' }] },
|
|
],
|
|
},
|
|
...overrides,
|
|
})
|
|
|
|
// Version list is provided by the mocked useVersionListOfPlugin hook
|
|
|
|
// ================================
|
|
// Helper Functions
|
|
// ================================
|
|
|
|
const createQueryClient = () => new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
},
|
|
},
|
|
})
|
|
|
|
const renderWithQueryClient = (ui: React.ReactElement) => {
|
|
const queryClient = createQueryClient()
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
{ui}
|
|
</QueryClientProvider>,
|
|
)
|
|
}
|
|
|
|
// ================================
|
|
// Test Suites
|
|
// ================================
|
|
|
|
describe('update-plugin', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPortalOpen = false
|
|
mockCheck.mockResolvedValue({ status: TaskStatus.success })
|
|
})
|
|
|
|
// ============================================================
|
|
// UpdatePlugin (index.tsx) - Main Entry Component Tests
|
|
// ============================================================
|
|
describe('UpdatePlugin (index.tsx)', () => {
|
|
describe('Rendering', () => {
|
|
it('should render UpdateFromGitHub when type is github', () => {
|
|
// Arrange
|
|
const props: UpdatePluginModalType = {
|
|
type: PluginSource.github,
|
|
category: PluginCategoryEnum.tool,
|
|
github: createMockGitHubPayload(),
|
|
onCancel: vi.fn(),
|
|
onSave: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<UpdatePlugin {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('install-from-github')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render UpdateFromMarketplace when type is marketplace', () => {
|
|
// Arrange
|
|
const props: UpdatePluginModalType = {
|
|
type: PluginSource.marketplace,
|
|
category: PluginCategoryEnum.tool,
|
|
marketPlace: createMockMarketPlacePayload(),
|
|
onCancel: vi.fn(),
|
|
onSave: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
renderWithQueryClient(<UpdatePlugin {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Update Plugin')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render UpdateFromMarketplace for other plugin sources', () => {
|
|
// Arrange
|
|
const props: UpdatePluginModalType = {
|
|
type: PluginSource.local,
|
|
category: PluginCategoryEnum.tool,
|
|
marketPlace: createMockMarketPlacePayload(),
|
|
onCancel: vi.fn(),
|
|
onSave: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
renderWithQueryClient(<UpdatePlugin {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Update Plugin')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Component Memoization', () => {
|
|
it('should be memoized with React.memo', () => {
|
|
// Verify the component is wrapped with React.memo
|
|
expect(UpdatePlugin).toBeDefined()
|
|
// The component should have $$typeof indicating it's a memo component
|
|
expect((UpdatePlugin as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
|
|
describe('Props Passing', () => {
|
|
it('should pass correct props to UpdateFromGitHub', () => {
|
|
// Arrange
|
|
const githubPayload = createMockGitHubPayload()
|
|
const onCancel = vi.fn()
|
|
const onSave = vi.fn()
|
|
const props: UpdatePluginModalType = {
|
|
type: PluginSource.github,
|
|
category: PluginCategoryEnum.tool,
|
|
github: githubPayload,
|
|
onCancel,
|
|
onSave,
|
|
}
|
|
|
|
// Act
|
|
render(<UpdatePlugin {...props} />)
|
|
|
|
// Assert
|
|
const payloadElement = screen.getByTestId('github-payload')
|
|
expect(payloadElement.textContent).toBe(JSON.stringify(githubPayload))
|
|
})
|
|
|
|
it('should call onCancel when github close is triggered', () => {
|
|
// Arrange
|
|
const onCancel = vi.fn()
|
|
const props: UpdatePluginModalType = {
|
|
type: PluginSource.github,
|
|
category: PluginCategoryEnum.tool,
|
|
github: createMockGitHubPayload(),
|
|
onCancel,
|
|
onSave: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<UpdatePlugin {...props} />)
|
|
fireEvent.click(screen.getByTestId('github-close'))
|
|
|
|
// Assert
|
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should call onSave when github success is triggered', () => {
|
|
// Arrange
|
|
const onSave = vi.fn()
|
|
const props: UpdatePluginModalType = {
|
|
type: PluginSource.github,
|
|
category: PluginCategoryEnum.tool,
|
|
github: createMockGitHubPayload(),
|
|
onCancel: vi.fn(),
|
|
onSave,
|
|
}
|
|
|
|
// Act
|
|
render(<UpdatePlugin {...props} />)
|
|
fireEvent.click(screen.getByTestId('github-success'))
|
|
|
|
// Assert
|
|
expect(onSave).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// FromGitHub (from-github.tsx) Tests
|
|
// ============================================================
|
|
describe('FromGitHub (from-github.tsx)', () => {
|
|
describe('Rendering', () => {
|
|
it('should render InstallFromGitHub with correct props', () => {
|
|
// Arrange
|
|
const payload = createMockGitHubPayload()
|
|
const onSave = vi.fn()
|
|
const onCancel = vi.fn()
|
|
|
|
// Act
|
|
render(
|
|
<FromGitHub
|
|
payload={payload}
|
|
onSave={onSave}
|
|
onCancel={onCancel}
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('install-from-github')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Component Memoization', () => {
|
|
it('should be memoized with React.memo', () => {
|
|
expect(FromGitHub).toBeDefined()
|
|
expect((FromGitHub as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
|
|
describe('Event Handlers', () => {
|
|
it('should call onCancel when onClose is triggered', () => {
|
|
// Arrange
|
|
const onCancel = vi.fn()
|
|
|
|
// Act
|
|
render(
|
|
<FromGitHub
|
|
payload={createMockGitHubPayload()}
|
|
onSave={vi.fn()}
|
|
onCancel={onCancel}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByTestId('github-close'))
|
|
|
|
// Assert
|
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should call onSave when onSuccess is triggered', () => {
|
|
// Arrange
|
|
const onSave = vi.fn()
|
|
|
|
// Act
|
|
render(
|
|
<FromGitHub
|
|
payload={createMockGitHubPayload()}
|
|
onSave={onSave}
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByTestId('github-success'))
|
|
|
|
// Assert
|
|
expect(onSave).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// UpdateFromMarketplace (from-market-place.tsx) Tests
|
|
// ============================================================
|
|
describe('UpdateFromMarketplace (from-market-place.tsx)', () => {
|
|
describe('Rendering', () => {
|
|
it('should render modal with title and description', () => {
|
|
// Arrange
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={vi.fn()}
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Update Plugin')).toBeInTheDocument()
|
|
expect(screen.getByText('This plugin will be updated to the new version.')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render version badge with version transition', () => {
|
|
// Arrange
|
|
const payload = createMockMarketPlacePayload({
|
|
originalPackageInfo: {
|
|
id: 'original-id',
|
|
payload: createMockPluginDeclaration({ version: '1.0.0' }),
|
|
},
|
|
targetPackageInfo: {
|
|
id: 'target-id',
|
|
version: '2.0.0',
|
|
},
|
|
})
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={vi.fn()}
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.getByText('1.0.0 -> 2.0.0')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render Update button in initial state', () => {
|
|
// Arrange
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={vi.fn()}
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Downgrade Warning Modal', () => {
|
|
it('should show downgrade warning modal when isShowDowngradeWarningModal is true', () => {
|
|
// Arrange
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={vi.fn()}
|
|
onCancel={vi.fn()}
|
|
isShowDowngradeWarningModal={true}
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Downgrade Warning')).toBeInTheDocument()
|
|
expect(screen.getByText('You are about to downgrade this plugin.')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should not show downgrade warning modal when isShowDowngradeWarningModal is false', () => {
|
|
// Arrange
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={vi.fn()}
|
|
onCancel={vi.fn()}
|
|
isShowDowngradeWarningModal={false}
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.queryByText('Downgrade Warning')).not.toBeInTheDocument()
|
|
expect(screen.getByText('Update Plugin')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should call onCancel when Cancel button is clicked', () => {
|
|
// Arrange
|
|
const onCancel = vi.fn()
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={vi.fn()}
|
|
onCancel={onCancel}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
|
|
// Assert
|
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should call updateFromMarketPlace API when Update button is clicked', async () => {
|
|
// Arrange
|
|
mockUpdateFromMarketPlace.mockResolvedValue({
|
|
all_installed: true,
|
|
task_id: 'task-123',
|
|
})
|
|
const onSave = vi.fn()
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={onSave}
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Update' }))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockUpdateFromMarketPlace).toHaveBeenCalledWith({
|
|
original_plugin_unique_identifier: 'original-id',
|
|
new_plugin_unique_identifier: 'test-target-id',
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should show loading state during upgrade', async () => {
|
|
// Arrange
|
|
mockUpdateFromMarketPlace.mockImplementation(() => new Promise(() => {})) // Never resolves
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={vi.fn()}
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
// Assert - button should show Update before clicking
|
|
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument()
|
|
|
|
// Act - click update button
|
|
fireEvent.click(screen.getByRole('button', { name: 'Update' }))
|
|
|
|
// Assert - Cancel button should be hidden during upgrade
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should call onSave when update completes with all_installed true', async () => {
|
|
// Arrange
|
|
mockUpdateFromMarketPlace.mockResolvedValue({
|
|
all_installed: true,
|
|
task_id: 'task-123',
|
|
})
|
|
const onSave = vi.fn()
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={onSave}
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Update' }))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(onSave).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should check task status when all_installed is false', async () => {
|
|
// Arrange
|
|
mockUpdateFromMarketPlace.mockResolvedValue({
|
|
all_installed: false,
|
|
task_id: 'task-123',
|
|
})
|
|
mockCheck.mockResolvedValue({ status: TaskStatus.success })
|
|
const onSave = vi.fn()
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={onSave}
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Update' }))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockHandleRefetch).toHaveBeenCalled()
|
|
})
|
|
await waitFor(() => {
|
|
expect(mockCheck).toHaveBeenCalledWith({
|
|
taskId: 'task-123',
|
|
pluginUniqueIdentifier: 'test-target-id',
|
|
})
|
|
})
|
|
})
|
|
|
|
it('should stop task check and call onCancel when modal is cancelled during upgrade', () => {
|
|
// Arrange
|
|
const onCancel = vi.fn()
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={vi.fn()}
|
|
onCancel={onCancel}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
|
|
// Assert
|
|
expect(mockStop).toHaveBeenCalled()
|
|
expect(onCancel).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('Error Handling', () => {
|
|
it('should reset to notStarted state when API call fails', async () => {
|
|
// Arrange
|
|
mockUpdateFromMarketPlace.mockRejectedValue(new Error('API Error'))
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={vi.fn()}
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Update' }))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show error toast when task status is failed', async () => {
|
|
// Arrange - covers lines 99-100
|
|
const mockToastNotify = vi.fn()
|
|
vi.mocked(await import('../../base/toast')).default.notify = mockToastNotify
|
|
|
|
mockUpdateFromMarketPlace.mockResolvedValue({
|
|
all_installed: false,
|
|
task_id: 'task-123',
|
|
})
|
|
mockCheck.mockResolvedValue({
|
|
status: TaskStatus.failed,
|
|
error: 'Installation failed due to dependency conflict',
|
|
})
|
|
const onSave = vi.fn()
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
onSave={onSave}
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Update' }))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockCheck).toHaveBeenCalled()
|
|
})
|
|
await waitFor(() => {
|
|
expect(mockToastNotify).toHaveBeenCalledWith({
|
|
type: 'error',
|
|
message: 'Installation failed due to dependency conflict',
|
|
})
|
|
})
|
|
// onSave should NOT be called when task fails
|
|
expect(onSave).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('Component Memoization', () => {
|
|
it('should be memoized with React.memo', () => {
|
|
expect(UpdateFromMarketplace).toBeDefined()
|
|
expect((UpdateFromMarketplace as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
|
|
describe('Exclude and Downgrade', () => {
|
|
it('should call mutateAsync and handleConfirm when exclude and downgrade is clicked', async () => {
|
|
// Arrange
|
|
mockMutateAsync.mockResolvedValue({})
|
|
mockUpdateFromMarketPlace.mockResolvedValue({
|
|
all_installed: true,
|
|
task_id: 'task-123',
|
|
})
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
pluginId="test-plugin-id"
|
|
onSave={vi.fn()}
|
|
onCancel={vi.fn()}
|
|
isShowDowngradeWarningModal={true}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Exclude and Downgrade' }))
|
|
|
|
// Assert
|
|
await waitFor(() => {
|
|
expect(mockMutateAsync).toHaveBeenCalledWith({
|
|
plugin_id: 'test-plugin-id',
|
|
})
|
|
})
|
|
await waitFor(() => {
|
|
expect(mockInvalidateReferenceSettings).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should skip mutateAsync when pluginId is not provided', async () => {
|
|
// Arrange - covers line 114 else branch
|
|
mockMutateAsync.mockResolvedValue({})
|
|
mockUpdateFromMarketPlace.mockResolvedValue({
|
|
all_installed: true,
|
|
task_id: 'task-123',
|
|
})
|
|
const payload = createMockMarketPlacePayload()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<UpdateFromMarketplace
|
|
payload={payload}
|
|
// pluginId is intentionally not provided
|
|
onSave={vi.fn()}
|
|
onCancel={vi.fn()}
|
|
isShowDowngradeWarningModal={true}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Exclude and Downgrade' }))
|
|
|
|
// Assert - mutateAsync should NOT be called when pluginId is undefined
|
|
await waitFor(() => {
|
|
expect(mockInvalidateReferenceSettings).toHaveBeenCalled()
|
|
})
|
|
expect(mockMutateAsync).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// DowngradeWarningModal (downgrade-warning.tsx) Tests
|
|
// ============================================================
|
|
describe('DowngradeWarningModal (downgrade-warning.tsx)', () => {
|
|
describe('Rendering', () => {
|
|
it('should render title and description', () => {
|
|
// Act
|
|
render(
|
|
<DowngradeWarningModal
|
|
onCancel={vi.fn()}
|
|
onJustDowngrade={vi.fn()}
|
|
onExcludeAndDowngrade={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Downgrade Warning')).toBeInTheDocument()
|
|
expect(screen.getByText('You are about to downgrade this plugin.')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render all three action buttons', () => {
|
|
// Act
|
|
render(
|
|
<DowngradeWarningModal
|
|
onCancel={vi.fn()}
|
|
onJustDowngrade={vi.fn()}
|
|
onExcludeAndDowngrade={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Just Downgrade' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Exclude and Downgrade' })).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should call onCancel when Cancel button is clicked', () => {
|
|
// Arrange
|
|
const onCancel = vi.fn()
|
|
|
|
// Act
|
|
render(
|
|
<DowngradeWarningModal
|
|
onCancel={onCancel}
|
|
onJustDowngrade={vi.fn()}
|
|
onExcludeAndDowngrade={vi.fn()}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
|
|
// Assert
|
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should call onJustDowngrade when Just Downgrade button is clicked', () => {
|
|
// Arrange
|
|
const onJustDowngrade = vi.fn()
|
|
|
|
// Act
|
|
render(
|
|
<DowngradeWarningModal
|
|
onCancel={vi.fn()}
|
|
onJustDowngrade={onJustDowngrade}
|
|
onExcludeAndDowngrade={vi.fn()}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Just Downgrade' }))
|
|
|
|
// Assert
|
|
expect(onJustDowngrade).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should call onExcludeAndDowngrade when Exclude and Downgrade button is clicked', () => {
|
|
// Arrange
|
|
const onExcludeAndDowngrade = vi.fn()
|
|
|
|
// Act
|
|
render(
|
|
<DowngradeWarningModal
|
|
onCancel={vi.fn()}
|
|
onJustDowngrade={vi.fn()}
|
|
onExcludeAndDowngrade={onExcludeAndDowngrade}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Exclude and Downgrade' }))
|
|
|
|
// Assert
|
|
expect(onExcludeAndDowngrade).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// PluginVersionPicker (plugin-version-picker.tsx) Tests
|
|
// ============================================================
|
|
describe('PluginVersionPicker (plugin-version-picker.tsx)', () => {
|
|
const defaultProps = {
|
|
isShow: false,
|
|
onShowChange: vi.fn(),
|
|
pluginID: 'test-plugin-id',
|
|
currentVersion: '1.0.0',
|
|
trigger: <button>Select Version</button>,
|
|
onSelect: vi.fn(),
|
|
}
|
|
|
|
describe('Rendering', () => {
|
|
it('should render trigger element', () => {
|
|
// Act
|
|
render(<PluginVersionPicker {...defaultProps} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Select Version')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should not render content when isShow is false', () => {
|
|
// Act
|
|
render(<PluginVersionPicker {...defaultProps} isShow={false} />)
|
|
|
|
// Assert
|
|
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should render version list when isShow is true', () => {
|
|
// Act
|
|
render(<PluginVersionPicker {...defaultProps} isShow={true} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
|
expect(screen.getByText('Switch Version')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render all versions from API', () => {
|
|
// Act
|
|
render(<PluginVersionPicker {...defaultProps} isShow={true} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('1.0.0')).toBeInTheDocument()
|
|
expect(screen.getByText('1.1.0')).toBeInTheDocument()
|
|
expect(screen.getByText('2.0.0')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show CURRENT badge for current version', () => {
|
|
// Act
|
|
render(<PluginVersionPicker {...defaultProps} isShow={true} currentVersion="1.0.0" />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('CURRENT')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should call onShowChange when trigger is clicked', () => {
|
|
// Arrange
|
|
const onShowChange = vi.fn()
|
|
|
|
// Act
|
|
render(<PluginVersionPicker {...defaultProps} onShowChange={onShowChange} />)
|
|
fireEvent.click(screen.getByTestId('portal-trigger'))
|
|
|
|
// Assert
|
|
expect(onShowChange).toHaveBeenCalledWith(true)
|
|
})
|
|
|
|
it('should not call onShowChange when trigger is clicked and disabled is true', () => {
|
|
// Arrange
|
|
const onShowChange = vi.fn()
|
|
|
|
// Act
|
|
render(<PluginVersionPicker {...defaultProps} disabled={true} onShowChange={onShowChange} />)
|
|
fireEvent.click(screen.getByTestId('portal-trigger'))
|
|
|
|
// Assert
|
|
expect(onShowChange).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should call onSelect with correct params when a version is selected', () => {
|
|
// Arrange
|
|
const onSelect = vi.fn()
|
|
const onShowChange = vi.fn()
|
|
|
|
// Act
|
|
render(
|
|
<PluginVersionPicker
|
|
{...defaultProps}
|
|
isShow={true}
|
|
currentVersion="1.0.0"
|
|
onSelect={onSelect}
|
|
onShowChange={onShowChange}
|
|
/>,
|
|
)
|
|
// Click on version 2.0.0
|
|
const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/)
|
|
const version2Element = versionElements.find(el => el.textContent === '2.0.0')
|
|
if (version2Element) {
|
|
fireEvent.click(version2Element.closest('div[class*="cursor-pointer"]')!)
|
|
}
|
|
|
|
// Assert
|
|
expect(onSelect).toHaveBeenCalledWith({
|
|
version: '2.0.0',
|
|
unique_identifier: 'plugin-v2.0.0',
|
|
isDowngrade: false,
|
|
})
|
|
expect(onShowChange).toHaveBeenCalledWith(false)
|
|
})
|
|
|
|
it('should not call onSelect when clicking on current version', () => {
|
|
// Arrange
|
|
const onSelect = vi.fn()
|
|
|
|
// Act
|
|
render(
|
|
<PluginVersionPicker
|
|
{...defaultProps}
|
|
isShow={true}
|
|
currentVersion="1.0.0"
|
|
onSelect={onSelect}
|
|
/>,
|
|
)
|
|
// Click on current version 1.0.0
|
|
const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/)
|
|
const version1Element = versionElements.find(el => el.textContent === '1.0.0')
|
|
if (version1Element) {
|
|
fireEvent.click(version1Element.closest('div[class*="cursor"]')!)
|
|
}
|
|
|
|
// Assert
|
|
expect(onSelect).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should indicate downgrade when selecting a lower version', () => {
|
|
// Arrange
|
|
const onSelect = vi.fn()
|
|
|
|
// Act
|
|
render(
|
|
<PluginVersionPicker
|
|
{...defaultProps}
|
|
isShow={true}
|
|
currentVersion="2.0.0"
|
|
onSelect={onSelect}
|
|
/>,
|
|
)
|
|
// Click on version 1.0.0 (downgrade)
|
|
const versionElements = screen.getAllByText(/^\d+\.\d+\.\d+$/)
|
|
const version1Element = versionElements.find(el => el.textContent === '1.0.0')
|
|
if (version1Element) {
|
|
fireEvent.click(version1Element.closest('div[class*="cursor-pointer"]')!)
|
|
}
|
|
|
|
// Assert
|
|
expect(onSelect).toHaveBeenCalledWith({
|
|
version: '1.0.0',
|
|
unique_identifier: 'plugin-v1.0.0',
|
|
isDowngrade: true,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Props', () => {
|
|
it('should support custom placement', () => {
|
|
// Act
|
|
render(
|
|
<PluginVersionPicker
|
|
{...defaultProps}
|
|
isShow={true}
|
|
placement="top-end"
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should support custom offset', () => {
|
|
// Act
|
|
render(
|
|
<PluginVersionPicker
|
|
{...defaultProps}
|
|
isShow={true}
|
|
offset={{ mainAxis: 10, crossAxis: 20 }}
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Component Memoization', () => {
|
|
it('should be memoized with React.memo', () => {
|
|
expect(PluginVersionPicker).toBeDefined()
|
|
expect((PluginVersionPicker as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Edge Cases
|
|
// ============================================================
|
|
describe('Edge Cases', () => {
|
|
it('should render github update with undefined payload (mock handles it)', () => {
|
|
// Arrange - the mocked InstallFromGitHub handles undefined payload
|
|
const props: UpdatePluginModalType = {
|
|
type: PluginSource.github,
|
|
category: PluginCategoryEnum.tool,
|
|
github: undefined as unknown as UpdateFromGitHubPayload,
|
|
onCancel: vi.fn(),
|
|
onSave: vi.fn(),
|
|
}
|
|
|
|
// Act
|
|
render(<UpdatePlugin {...props} />)
|
|
|
|
// Assert - mock component renders with undefined payload
|
|
expect(screen.getByTestId('install-from-github')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should throw error when marketplace payload is undefined', () => {
|
|
// Arrange
|
|
const props: UpdatePluginModalType = {
|
|
type: PluginSource.marketplace,
|
|
category: PluginCategoryEnum.tool,
|
|
marketPlace: undefined as unknown as UpdateFromMarketPlacePayload,
|
|
onCancel: vi.fn(),
|
|
onSave: vi.fn(),
|
|
}
|
|
|
|
// Act & Assert - should throw because payload is required
|
|
expect(() => renderWithQueryClient(<UpdatePlugin {...props} />)).toThrow()
|
|
})
|
|
|
|
it('should handle empty version list in PluginVersionPicker', () => {
|
|
// Override the mock temporarily
|
|
vi.mocked(vi.importActual('@/service/use-plugins') as any).useVersionListOfPlugin = () => ({
|
|
data: { data: { versions: [] } },
|
|
})
|
|
|
|
// Act
|
|
render(
|
|
<PluginVersionPicker {...{
|
|
isShow: true,
|
|
onShowChange: vi.fn(),
|
|
pluginID: 'test',
|
|
currentVersion: '1.0.0',
|
|
trigger: <button>Select</button>,
|
|
onSelect: vi.fn(),
|
|
}}
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Switch Version')).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|