mirror of https://github.com/langgenius/dify.git
test: add unit tests for install plugin components including GitHub, local package, and marketplace installations
This commit is contained in:
parent
5a90b027ac
commit
1149b2f1f3
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,622 @@
|
|||
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, params?: Record<string, unknown>) => {
|
||||
if (params) {
|
||||
return `${key}:${JSON.stringify(params)}`
|
||||
}
|
||||
return key
|
||||
},
|
||||
}),
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
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, params?: Record<string, unknown>) => {
|
||||
if (params) {
|
||||
return `${key}:${JSON.stringify(params)}`
|
||||
}
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onPackageUploaded when package upload succeeds (no error message)', 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 bundle upload succeeds (no error message)', 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,683 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Import component after mocks are set up
|
||||
import Description from './index'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies
|
||||
// ================================
|
||||
|
||||
// Track mock locale for testing
|
||||
let mockDefaultLocale = 'en-US'
|
||||
|
||||
// Mock translations with realistic values
|
||||
const pluginTranslations: Record<string, string> = {
|
||||
'marketplace.empower': 'Empower your AI development',
|
||||
'marketplace.discover': 'Discover',
|
||||
'marketplace.difyMarketplace': 'Dify Marketplace',
|
||||
'marketplace.and': 'and',
|
||||
'category.models': 'Models',
|
||||
'category.tools': 'Tools',
|
||||
'category.datasources': 'Data Sources',
|
||||
'category.triggers': 'Triggers',
|
||||
'category.agents': 'Agent Strategies',
|
||||
'category.extensions': 'Extensions',
|
||||
'category.bundles': 'Bundles',
|
||||
}
|
||||
|
||||
const commonTranslations: Record<string, string> = {
|
||||
'operation.in': 'in',
|
||||
}
|
||||
|
||||
// Mock getLocaleOnServer and translate
|
||||
vi.mock('@/i18n-config/server', () => ({
|
||||
getLocaleOnServer: vi.fn(() => Promise.resolve(mockDefaultLocale)),
|
||||
getTranslation: vi.fn((locale: string, ns: string) => {
|
||||
return Promise.resolve({
|
||||
t: (key: string) => {
|
||||
if (ns === 'plugin')
|
||||
return pluginTranslations[key] || key
|
||||
if (ns === 'common')
|
||||
return commonTranslations[key] || key
|
||||
return key
|
||||
},
|
||||
})
|
||||
}),
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Description Component Tests
|
||||
// ================================
|
||||
describe('Description', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDefaultLocale = 'en-US'
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render h1 heading with empower text', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeInTheDocument()
|
||||
expect(heading).toHaveTextContent('Empower your AI development')
|
||||
})
|
||||
|
||||
it('should render h2 subheading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply correct CSS classes to h1', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toHaveClass('title-4xl-semi-bold')
|
||||
expect(heading).toHaveClass('mb-2')
|
||||
expect(heading).toHaveClass('text-center')
|
||||
expect(heading).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should apply correct CSS classes to h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('body-md-regular')
|
||||
expect(subheading).toHaveClass('text-center')
|
||||
expect(subheading).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Non-Chinese Locale Rendering Tests
|
||||
// ================================
|
||||
describe('Non-Chinese Locale Rendering', () => {
|
||||
it('should render discover text for en-US locale', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(screen.getByText(/Discover/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all category names', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(screen.getByText('Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tools')).toBeInTheDocument()
|
||||
expect(screen.getByText('Data Sources')).toBeInTheDocument()
|
||||
expect(screen.getByText('Triggers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bundles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "and" conjunction text', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('and')
|
||||
})
|
||||
|
||||
it('should render "in" preposition at the end for non-Chinese locales', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(screen.getByText('in')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Dify Marketplace text at the end for non-Chinese locales', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should render category spans with styled underline effect', async () => {
|
||||
const { container } = render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const styledSpans = container.querySelectorAll('.body-md-medium.relative.z-\\[1\\]')
|
||||
// 7 category spans (models, tools, datasources, triggers, agents, extensions, bundles)
|
||||
expect(styledSpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply text-text-secondary class to category spans', async () => {
|
||||
const { container } = render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const styledSpans = container.querySelectorAll('.text-text-secondary')
|
||||
expect(styledSpans.length).toBeGreaterThanOrEqual(7)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Chinese (zh-Hans) Locale Rendering Tests
|
||||
// ================================
|
||||
describe('Chinese (zh-Hans) Locale Rendering', () => {
|
||||
it('should render "in" text at the beginning for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// In zh-Hans mode, "in" appears at the beginning
|
||||
const inElements = screen.getAllByText('in')
|
||||
expect(inElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render Dify Marketplace text for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should render discover text for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
expect(screen.getByText(/Discover/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all categories for zh-Hans locale', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
expect(screen.getByText('Models')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tools')).toBeInTheDocument()
|
||||
expect(screen.getByText('Data Sources')).toBeInTheDocument()
|
||||
expect(screen.getByText('Triggers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent Strategies')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bundles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both zh-Hans specific elements and shared elements', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// zh-Hans has specific element order: "in" -> Dify Marketplace -> Discover
|
||||
// then the same category list with "and" -> Bundles
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('and')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Locale Prop Variations Tests
|
||||
// ================================
|
||||
describe('Locale Prop Variations', () => {
|
||||
it('should use default locale when locale prop is undefined', async () => {
|
||||
mockDefaultLocale = 'en-US'
|
||||
render(await Description({}))
|
||||
|
||||
// Should use the default locale from getLocaleOnServer
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use provided locale prop instead of default', async () => {
|
||||
mockDefaultLocale = 'ja-JP'
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// The locale prop should be used, triggering non-Chinese rendering
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ja-JP locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'ja-JP' }))
|
||||
|
||||
// Should render in non-Chinese format (discover first, then "in Dify Marketplace" at end)
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should handle ko-KR locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'ko-KR' }))
|
||||
|
||||
// Should render in non-Chinese format
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle de-DE locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'de-DE' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle fr-FR locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'fr-FR' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle pt-BR locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'pt-BR' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle es-ES locale as non-Chinese', async () => {
|
||||
render(await Description({ locale: 'es-ES' }))
|
||||
|
||||
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Conditional Rendering Tests
|
||||
// ================================
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should render zh-Hans specific content when locale is zh-Hans', async () => {
|
||||
const { container } = render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// zh-Hans has additional span with mr-1 before "in" text at the start
|
||||
const mrSpan = container.querySelector('span.mr-1')
|
||||
expect(mrSpan).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render non-Chinese specific content when locale is not zh-Hans', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// Non-Chinese has "in" and "Dify Marketplace" at the end
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading.textContent).toContain('Dify Marketplace')
|
||||
})
|
||||
|
||||
it('should not render zh-Hans intro content for non-Chinese locales', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// For en-US, the order should be Discover ... in Dify Marketplace
|
||||
// The "in" text should only appear once at the end
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// "in" should appear after "Bundles" and before "Dify Marketplace"
|
||||
const bundlesIndex = content.indexOf('Bundles')
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
|
||||
expect(bundlesIndex).toBeLessThan(inIndex)
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
})
|
||||
|
||||
it('should render zh-Hans with proper word order', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
expect(marketplaceIndex).toBeLessThan(discoverIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Category Styling Tests
|
||||
// ================================
|
||||
describe('Category Styling', () => {
|
||||
it('should apply underline effect with after pseudo-element styling', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpan = container.querySelector('.after\\:absolute')
|
||||
expect(categorySpan).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply correct after pseudo-element classes', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
// Check for the specific after pseudo-element classes
|
||||
const categorySpans = container.querySelectorAll('.after\\:bottom-\\[1\\.5px\\]')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply full width to after element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:w-full')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply correct height to after element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:h-2')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply bg-text-text-selected to after element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.after\\:bg-text-text-selected')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should have z-index 1 on category spans', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.z-\\[1\\]')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should apply left margin to category spans', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
const categorySpans = container.querySelectorAll('.ml-1')
|
||||
expect(categorySpans.length).toBeGreaterThanOrEqual(7)
|
||||
})
|
||||
|
||||
it('should apply both left and right margin to specific spans', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
// Extensions and Bundles spans have both ml-1 and mr-1
|
||||
const extensionsBundlesSpans = container.querySelectorAll('.ml-1.mr-1')
|
||||
expect(extensionsBundlesSpans.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty props object', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fragment as root element', async () => {
|
||||
const { container } = render(await Description({}))
|
||||
|
||||
// Fragment renders h1 and h2 as direct children
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle locale prop with undefined value', async () => {
|
||||
render(await Description({ locale: undefined }))
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zh-Hant as non-Chinese simplified', async () => {
|
||||
render(await Description({ locale: 'zh-Hant' }))
|
||||
|
||||
// zh-Hant is different from zh-Hans, should use non-Chinese format
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// Check that "Dify Marketplace" appears at the end (non-Chinese format)
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
|
||||
// For non-Chinese locales, Discover should come before Dify Marketplace
|
||||
expect(discoverIndex).toBeLessThan(marketplaceIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Content Structure Tests
|
||||
// ================================
|
||||
describe('Content Structure', () => {
|
||||
it('should have comma separators between categories', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// Commas should exist between categories
|
||||
expect(content).toMatch(/Models[^\n\r,\u2028\u2029]*,.*Tools[^\n\r,\u2028\u2029]*,.*Data Sources[^\n\r,\u2028\u2029]*,.*Triggers[^\n\r,\u2028\u2029]*,.*Agent Strategies[^\n\r,\u2028\u2029]*,.*Extensions/)
|
||||
})
|
||||
|
||||
it('should have "and" before last category (Bundles)', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// "and" should appear before Bundles
|
||||
const andIndex = content.indexOf('and')
|
||||
const bundlesIndex = content.indexOf('Bundles')
|
||||
|
||||
expect(andIndex).toBeLessThan(bundlesIndex)
|
||||
})
|
||||
|
||||
it('should render all text elements in correct order for en-US', async () => {
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
const expectedOrder = [
|
||||
'Discover',
|
||||
'Models',
|
||||
'Tools',
|
||||
'Data Sources',
|
||||
'Triggers',
|
||||
'Agent Strategies',
|
||||
'Extensions',
|
||||
'and',
|
||||
'Bundles',
|
||||
'in',
|
||||
'Dify Marketplace',
|
||||
]
|
||||
|
||||
let lastIndex = -1
|
||||
for (const text of expectedOrder) {
|
||||
const currentIndex = content.indexOf(text)
|
||||
expect(currentIndex).toBeGreaterThan(lastIndex)
|
||||
lastIndex = currentIndex
|
||||
}
|
||||
})
|
||||
|
||||
it('should render all text elements in correct order for zh-Hans', async () => {
|
||||
render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
const content = subheading.textContent || ''
|
||||
|
||||
// zh-Hans order: in -> Dify Marketplace -> Discover -> categories -> and -> Bundles
|
||||
const inIndex = content.indexOf('in')
|
||||
const marketplaceIndex = content.indexOf('Dify Marketplace')
|
||||
const discoverIndex = content.indexOf('Discover')
|
||||
const modelsIndex = content.indexOf('Models')
|
||||
|
||||
expect(inIndex).toBeLessThan(marketplaceIndex)
|
||||
expect(marketplaceIndex).toBeLessThan(discoverIndex)
|
||||
expect(discoverIndex).toBeLessThan(modelsIndex)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Layout Tests
|
||||
// ================================
|
||||
describe('Layout', () => {
|
||||
it('should have shrink-0 on h1 heading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toHaveClass('shrink-0')
|
||||
})
|
||||
|
||||
it('should have shrink-0 on h2 subheading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('shrink-0')
|
||||
})
|
||||
|
||||
it('should have flex layout on h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('flex')
|
||||
})
|
||||
|
||||
it('should have items-center on h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('items-center')
|
||||
})
|
||||
|
||||
it('should have justify-center on h2', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toHaveClass('justify-center')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Translation Function Tests
|
||||
// ================================
|
||||
describe('Translation Functions', () => {
|
||||
it('should call getTranslation for plugin namespace', async () => {
|
||||
const { getTranslation } = await import('@/i18n-config/server')
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(getTranslation).toHaveBeenCalledWith('en-US', 'plugin')
|
||||
})
|
||||
|
||||
it('should call getTranslation for common namespace', async () => {
|
||||
const { getTranslation } = await import('@/i18n-config/server')
|
||||
render(await Description({ locale: 'en-US' }))
|
||||
|
||||
expect(getTranslation).toHaveBeenCalledWith('en-US', 'common')
|
||||
})
|
||||
|
||||
it('should call getLocaleOnServer when locale prop is undefined', async () => {
|
||||
const { getLocaleOnServer } = await import('@/i18n-config/server')
|
||||
render(await Description({}))
|
||||
|
||||
expect(getLocaleOnServer).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use locale prop when provided', async () => {
|
||||
const { getTranslation } = await import('@/i18n-config/server')
|
||||
render(await Description({ locale: 'ja-JP' }))
|
||||
|
||||
expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'plugin')
|
||||
expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'common')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Accessibility Tests
|
||||
// ================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 })
|
||||
const h2 = screen.getByRole('heading', { level: 2 })
|
||||
|
||||
expect(h1).toBeInTheDocument()
|
||||
expect(h2).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have readable text content', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 })
|
||||
expect(h1.textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('should have visible h1 heading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
it('should have visible h2 heading', async () => {
|
||||
render(await Description({}))
|
||||
|
||||
const subheading = screen.getByRole('heading', { level: 2 })
|
||||
expect(subheading).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Integration Tests
|
||||
// ================================
|
||||
describe('Description Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDefaultLocale = 'en-US'
|
||||
})
|
||||
|
||||
it('should render complete component structure', async () => {
|
||||
const { container } = render(await Description({ locale: 'en-US' }))
|
||||
|
||||
// Main headings
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
|
||||
// All category spans
|
||||
const categorySpans = container.querySelectorAll('.body-md-medium')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should render complete zh-Hans structure', async () => {
|
||||
const { container } = render(await Description({ locale: 'zh-Hans' }))
|
||||
|
||||
// Main headings
|
||||
expect(container.querySelector('h1')).toBeInTheDocument()
|
||||
expect(container.querySelector('h2')).toBeInTheDocument()
|
||||
|
||||
// All category spans
|
||||
const categorySpans = container.querySelectorAll('.body-md-medium')
|
||||
expect(categorySpans.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should correctly switch between zh-Hans and en-US layouts', async () => {
|
||||
// Render en-US
|
||||
const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' }))
|
||||
const enContent = enContainer.querySelector('h2')?.textContent || ''
|
||||
unmountEn()
|
||||
|
||||
// Render zh-Hans
|
||||
const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' }))
|
||||
const zhContent = zhContainer.querySelector('h2')?.textContent || ''
|
||||
|
||||
// Both should have all categories
|
||||
expect(enContent).toContain('Models')
|
||||
expect(zhContent).toContain('Models')
|
||||
|
||||
// But order should differ
|
||||
const enMarketplaceIndex = enContent.indexOf('Dify Marketplace')
|
||||
const enDiscoverIndex = enContent.indexOf('Discover')
|
||||
const zhMarketplaceIndex = zhContent.indexOf('Dify Marketplace')
|
||||
const zhDiscoverIndex = zhContent.indexOf('Discover')
|
||||
|
||||
// en-US: Discover comes before Dify Marketplace
|
||||
expect(enDiscoverIndex).toBeLessThan(enMarketplaceIndex)
|
||||
|
||||
// zh-Hans: Dify Marketplace comes before Discover
|
||||
expect(zhMarketplaceIndex).toBeLessThan(zhDiscoverIndex)
|
||||
})
|
||||
|
||||
it('should maintain consistent styling across locales', async () => {
|
||||
// Render en-US
|
||||
const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' }))
|
||||
const enCategoryCount = enContainer.querySelectorAll('.body-md-medium').length
|
||||
unmountEn()
|
||||
|
||||
// Render zh-Hans
|
||||
const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' }))
|
||||
const zhCategoryCount = zhContainer.querySelectorAll('.body-md-medium').length
|
||||
|
||||
// Both should have same number of styled category spans
|
||||
expect(enCategoryCount).toBe(zhCategoryCount)
|
||||
expect(enCategoryCount).toBe(7)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,834 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Empty from './index'
|
||||
import Line from './line'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
vi.mock('../hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'plugin.marketplace.noPluginFound': 'No plugin found',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useTheme hook with controllable theme value
|
||||
let mockTheme = 'light'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: mockTheme,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Line Component Tests
|
||||
// ================================
|
||||
describe('Line', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render SVG element', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Light Theme Tests
|
||||
// ================================
|
||||
describe('Light Theme', () => {
|
||||
beforeEach(() => {
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('should render light mode SVG', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('width', '2')
|
||||
expect(svg).toHaveAttribute('height', '241')
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 2 241')
|
||||
})
|
||||
|
||||
it('should render light mode path with correct d attribute', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('d', 'M1 0.5L1 240.5')
|
||||
})
|
||||
|
||||
it('should render light mode linear gradient with correct id', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const gradient = container.querySelector('#paint0_linear_1989_74474')
|
||||
expect(gradient).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render light mode gradient with white stop colors', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const stops = container.querySelectorAll('stop')
|
||||
expect(stops.length).toBe(3)
|
||||
|
||||
// First stop - white with 0.01 opacity
|
||||
expect(stops[0]).toHaveAttribute('stop-color', 'white')
|
||||
expect(stops[0]).toHaveAttribute('stop-opacity', '0.01')
|
||||
|
||||
// Middle stop - dark color with 0.08 opacity
|
||||
expect(stops[1]).toHaveAttribute('stop-color', '#101828')
|
||||
expect(stops[1]).toHaveAttribute('stop-opacity', '0.08')
|
||||
|
||||
// Last stop - white with 0.01 opacity
|
||||
expect(stops[2]).toHaveAttribute('stop-color', 'white')
|
||||
expect(stops[2]).toHaveAttribute('stop-opacity', '0.01')
|
||||
})
|
||||
|
||||
it('should apply className to SVG in light mode', () => {
|
||||
const { container } = render(<Line className="test-class" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('test-class')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Dark Theme Tests
|
||||
// ================================
|
||||
describe('Dark Theme', () => {
|
||||
beforeEach(() => {
|
||||
mockTheme = 'dark'
|
||||
})
|
||||
|
||||
it('should render dark mode SVG', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('width', '2')
|
||||
expect(svg).toHaveAttribute('height', '240')
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 2 240')
|
||||
})
|
||||
|
||||
it('should render dark mode path with correct d attribute', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('d', 'M1 0L1 240')
|
||||
})
|
||||
|
||||
it('should render dark mode linear gradient with correct id', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const gradient = container.querySelector('#paint0_linear_6295_52176')
|
||||
expect(gradient).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dark mode gradient stops', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const stops = container.querySelectorAll('stop')
|
||||
expect(stops.length).toBe(3)
|
||||
|
||||
// First stop - no color, 0.01 opacity
|
||||
expect(stops[0]).toHaveAttribute('stop-opacity', '0.01')
|
||||
|
||||
// Middle stop - light color with 0.14 opacity
|
||||
expect(stops[1]).toHaveAttribute('stop-color', '#C8CEDA')
|
||||
expect(stops[1]).toHaveAttribute('stop-opacity', '0.14')
|
||||
|
||||
// Last stop - no color, 0.01 opacity
|
||||
expect(stops[2]).toHaveAttribute('stop-opacity', '0.01')
|
||||
})
|
||||
|
||||
it('should apply className to SVG in dark mode', () => {
|
||||
const { container } = render(<Line className="dark-test-class" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('dark-test-class')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Variations Tests
|
||||
// ================================
|
||||
describe('Props Variations', () => {
|
||||
it('should handle undefined className', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string className', () => {
|
||||
const { container } = render(<Line className="" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple class names', () => {
|
||||
const { container } = render(<Line className="class-1 class-2 class-3" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('class-1')
|
||||
expect(svg).toHaveClass('class-2')
|
||||
expect(svg).toHaveClass('class-3')
|
||||
})
|
||||
|
||||
it('should handle Tailwind utility classes', () => {
|
||||
const { container } = render(
|
||||
<Line className="absolute right-[-1px] top-1/2 -translate-y-1/2" />,
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('absolute')
|
||||
expect(svg).toHaveClass('right-[-1px]')
|
||||
expect(svg).toHaveClass('top-1/2')
|
||||
expect(svg).toHaveClass('-translate-y-1/2')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Theme Switching Tests
|
||||
// ================================
|
||||
describe('Theme Switching', () => {
|
||||
it('should render different SVG dimensions based on theme', () => {
|
||||
// Light mode
|
||||
mockTheme = 'light'
|
||||
const { container: lightContainer, unmount: unmountLight } = render(<Line />)
|
||||
expect(lightContainer.querySelector('svg')).toHaveAttribute('height', '241')
|
||||
unmountLight()
|
||||
|
||||
// Dark mode
|
||||
mockTheme = 'dark'
|
||||
const { container: darkContainer } = render(<Line />)
|
||||
expect(darkContainer.querySelector('svg')).toHaveAttribute('height', '240')
|
||||
})
|
||||
|
||||
it('should use different gradient IDs based on theme', () => {
|
||||
// Light mode
|
||||
mockTheme = 'light'
|
||||
const { container: lightContainer, unmount: unmountLight } = render(<Line />)
|
||||
expect(lightContainer.querySelector('#paint0_linear_1989_74474')).toBeInTheDocument()
|
||||
expect(lightContainer.querySelector('#paint0_linear_6295_52176')).not.toBeInTheDocument()
|
||||
unmountLight()
|
||||
|
||||
// Dark mode
|
||||
mockTheme = 'dark'
|
||||
const { container: darkContainer } = render(<Line />)
|
||||
expect(darkContainer.querySelector('#paint0_linear_6295_52176')).toBeInTheDocument()
|
||||
expect(darkContainer.querySelector('#paint0_linear_1989_74474')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle theme value of light explicitly', () => {
|
||||
mockTheme = 'light'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
expect(container.querySelector('#paint0_linear_1989_74474')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle non-dark theme as light mode', () => {
|
||||
mockTheme = 'system'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
// Non-dark themes should use light mode SVG
|
||||
expect(container.querySelector('svg')).toHaveAttribute('height', '241')
|
||||
})
|
||||
|
||||
it('should render SVG with fill none', () => {
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('fill', 'none')
|
||||
})
|
||||
|
||||
it('should render path with gradient stroke', () => {
|
||||
mockTheme = 'light'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('stroke', 'url(#paint0_linear_1989_74474)')
|
||||
})
|
||||
|
||||
it('should render dark mode path with gradient stroke', () => {
|
||||
mockTheme = 'dark'
|
||||
const { container } = render(<Line />)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
expect(path).toHaveAttribute('stroke', 'url(#paint0_linear_6295_52176)')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Empty Component Tests
|
||||
// ================================
|
||||
describe('Empty', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render 16 placeholder cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should render default no plugin found text', () => {
|
||||
render(<Empty />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Group icon', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Icon wrapper should be present
|
||||
const iconWrapper = container.querySelector('.h-14.w-14')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render four Line components around the icon', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Four SVG elements from Line components + 1 Group icon SVG = 5 total
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBe(5)
|
||||
})
|
||||
|
||||
it('should render center content with absolute positioning', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const centerContent = container.querySelector('.absolute.left-1\\/2.top-1\\/2')
|
||||
expect(centerContent).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Text Prop Tests
|
||||
// ================================
|
||||
describe('Text Prop', () => {
|
||||
it('should render custom text when provided', () => {
|
||||
render(<Empty text="Custom empty message" />)
|
||||
|
||||
expect(screen.getByText('Custom empty message')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No plugin found')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render default translation when text is empty string', () => {
|
||||
render(<Empty text="" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render default translation when text is undefined', () => {
|
||||
render(<Empty text={undefined} />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render long custom text', () => {
|
||||
const longText = 'This is a very long message that describes why there are no plugins found in the current search results and what the user might want to do next to find what they are looking for'
|
||||
render(<Empty text={longText} />)
|
||||
|
||||
expect(screen.getByText(longText)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text with special characters', () => {
|
||||
render(<Empty text="No plugins found for query: <search>" />)
|
||||
|
||||
expect(screen.getByText('No plugins found for query: <search>')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// LightCard Prop Tests
|
||||
// ================================
|
||||
describe('LightCard Prop', () => {
|
||||
it('should render overlay when lightCard is false', () => {
|
||||
const { container } = render(<Empty lightCard={false} />)
|
||||
|
||||
const overlay = container.querySelector('.bg-marketplace-plugin-empty')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render overlay when lightCard is true', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const overlay = container.querySelector('.bg-marketplace-plugin-empty')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render overlay by default when lightCard is undefined', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const overlay = container.querySelector('.bg-marketplace-plugin-empty')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply light card styling to placeholder cards when lightCard is true', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should apply default styling to placeholder cards when lightCard is false', () => {
|
||||
const { container } = render(<Empty lightCard={false} />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-section-burn')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should apply opacity to light card placeholder', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const placeholderCards = container.querySelectorAll('.opacity-75')
|
||||
expect(placeholderCards.length).toBe(16)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// ClassName Prop Tests
|
||||
// ================================
|
||||
describe('ClassName Prop', () => {
|
||||
it('should apply custom className to container', () => {
|
||||
const { container } = render(<Empty className="custom-class" />)
|
||||
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve base classes when adding custom className', () => {
|
||||
const { container } = render(<Empty className="custom-class" />)
|
||||
|
||||
const element = container.querySelector('.custom-class')
|
||||
expect(element).toHaveClass('relative')
|
||||
expect(element).toHaveClass('flex')
|
||||
expect(element).toHaveClass('h-0')
|
||||
expect(element).toHaveClass('grow')
|
||||
})
|
||||
|
||||
it('should handle empty string className', () => {
|
||||
const { container } = render(<Empty className="" />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined className', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const element = container.firstChild as HTMLElement
|
||||
expect(element).toHaveClass('relative')
|
||||
})
|
||||
|
||||
it('should handle multiple custom classes', () => {
|
||||
const { container } = render(<Empty className="class-a class-b class-c" />)
|
||||
|
||||
const element = container.querySelector('.class-a')
|
||||
expect(element).toHaveClass('class-b')
|
||||
expect(element).toHaveClass('class-c')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Locale Prop Tests
|
||||
// ================================
|
||||
describe('Locale Prop', () => {
|
||||
it('should pass locale to useMixedTranslation', () => {
|
||||
render(<Empty locale="zh-CN" />)
|
||||
|
||||
// Translation should still work
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined locale', () => {
|
||||
render(<Empty locale={undefined} />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle en-US locale', () => {
|
||||
render(<Empty locale="en-US" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ja-JP locale', () => {
|
||||
render(<Empty locale="ja-JP" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Placeholder Cards Layout Tests
|
||||
// ================================
|
||||
describe('Placeholder Cards Layout', () => {
|
||||
it('should remove right margin on every 4th card', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards at indices 3, 7, 11, 15 (4th, 8th, 12th, 16th) should have mr-0
|
||||
expect(cards[3]).toHaveClass('mr-0')
|
||||
expect(cards[7]).toHaveClass('mr-0')
|
||||
expect(cards[11]).toHaveClass('mr-0')
|
||||
expect(cards[15]).toHaveClass('mr-0')
|
||||
})
|
||||
|
||||
it('should have margin on cards that are not at the end of row', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards not at row end should have mr-3
|
||||
expect(cards[0]).toHaveClass('mr-3')
|
||||
expect(cards[1]).toHaveClass('mr-3')
|
||||
expect(cards[2]).toHaveClass('mr-3')
|
||||
})
|
||||
|
||||
it('should remove bottom margin on last row cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards at indices 12, 13, 14, 15 should have mb-0
|
||||
expect(cards[12]).toHaveClass('mb-0')
|
||||
expect(cards[13]).toHaveClass('mb-0')
|
||||
expect(cards[14]).toHaveClass('mb-0')
|
||||
expect(cards[15]).toHaveClass('mb-0')
|
||||
})
|
||||
|
||||
it('should have bottom margin on non-last row cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.h-\\[144px\\]')
|
||||
|
||||
// Cards at indices 0-11 should have mb-3
|
||||
expect(cards[0]).toHaveClass('mb-3')
|
||||
expect(cards[5]).toHaveClass('mb-3')
|
||||
expect(cards[11]).toHaveClass('mb-3')
|
||||
})
|
||||
|
||||
it('should have correct width calculation for 4 columns', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.w-\\[calc\\(\\(100\\%-36px\\)\\/4\\)\\]')
|
||||
expect(cards.length).toBe(16)
|
||||
})
|
||||
|
||||
it('should have rounded corners on cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const cards = container.querySelectorAll('.rounded-xl')
|
||||
// 16 cards + 1 icon wrapper = 17 rounded-xl elements
|
||||
expect(cards.length).toBeGreaterThanOrEqual(16)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Icon Container Tests
|
||||
// ================================
|
||||
describe('Icon Container', () => {
|
||||
it('should render icon container with border', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const iconContainer = container.querySelector('.border-dashed')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon container with shadow', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const iconContainer = container.querySelector('.shadow-lg')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon container centered', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const centerWrapper = container.querySelector('.-translate-x-1\\/2.-translate-y-1\\/2')
|
||||
expect(centerWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have z-index for center content', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const centerContent = container.querySelector('.z-\\[2\\]')
|
||||
expect(centerContent).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Line Positioning Tests
|
||||
// ================================
|
||||
describe('Line Positioning', () => {
|
||||
it('should position Line components correctly around icon', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Right line
|
||||
const rightLine = container.querySelector('.right-\\[-1px\\]')
|
||||
expect(rightLine).toBeInTheDocument()
|
||||
|
||||
// Left line
|
||||
const leftLine = container.querySelector('.left-\\[-1px\\]')
|
||||
expect(leftLine).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have rotated Line components for top and bottom', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const rotatedLines = container.querySelectorAll('.rotate-90')
|
||||
expect(rotatedLines.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Combined Props Tests
|
||||
// ================================
|
||||
describe('Combined Props', () => {
|
||||
it('should handle all props together', () => {
|
||||
const { container } = render(
|
||||
<Empty
|
||||
text="Custom message"
|
||||
lightCard
|
||||
className="custom-wrapper"
|
||||
locale="en-US"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom message')).toBeInTheDocument()
|
||||
expect(container.querySelector('.custom-wrapper')).toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly with lightCard false and custom text', () => {
|
||||
const { container } = render(
|
||||
<Empty text="No results" lightCard={false} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('No results')).toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle className with lightCard prop', () => {
|
||||
const { container } = render(
|
||||
<Empty className="test-class" lightCard />,
|
||||
)
|
||||
|
||||
const element = container.querySelector('.test-class')
|
||||
expect(element).toBeInTheDocument()
|
||||
|
||||
// Verify light card styling is applied
|
||||
const lightCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(lightCards.length).toBe(16)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty props object', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only text prop', () => {
|
||||
render(<Empty text="Only text" />)
|
||||
|
||||
expect(screen.getByText('Only text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only lightCard prop', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only className prop', () => {
|
||||
const { container } = render(<Empty className="only-class" />)
|
||||
|
||||
expect(container.querySelector('.only-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with only locale prop', () => {
|
||||
render(<Empty locale="zh-CN" />)
|
||||
|
||||
expect(screen.getByText('No plugin found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle text with unicode characters', () => {
|
||||
render(<Empty text="没有找到插件 🔍" />)
|
||||
|
||||
expect(screen.getByText('没有找到插件 🔍')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle text with HTML entities', () => {
|
||||
render(<Empty text="No plugins & no results" />)
|
||||
|
||||
expect(screen.getByText('No plugins & no results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle whitespace-only text', () => {
|
||||
const { container } = render(<Empty text=" " />)
|
||||
|
||||
// Whitespace-only text is truthy, so it should be rendered
|
||||
const textContainer = container.querySelector('.system-md-regular')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
expect(textContainer?.textContent).toBe(' ')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Accessibility Tests
|
||||
// ================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have text content visible', () => {
|
||||
render(<Empty text="No plugins available" />)
|
||||
|
||||
const textElement = screen.getByText('No plugins available')
|
||||
expect(textElement).toBeVisible()
|
||||
})
|
||||
|
||||
it('should render text in proper container', () => {
|
||||
const { container } = render(<Empty text="Test message" />)
|
||||
|
||||
const textContainer = container.querySelector('.system-md-regular')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
expect(textContainer).toHaveTextContent('Test message')
|
||||
})
|
||||
|
||||
it('should center text content', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const textContainer = container.querySelector('.text-center')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Overlay Tests
|
||||
// ================================
|
||||
describe('Overlay', () => {
|
||||
it('should render overlay with correct z-index', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const overlay = container.querySelector('.z-\\[1\\]')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render overlay with full coverage', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
const overlay = container.querySelector('.inset-0')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render overlay when lightCard is true', () => {
|
||||
const { container } = render(<Empty lightCard />)
|
||||
|
||||
const overlay = container.querySelector('.inset-0.z-\\[1\\]')
|
||||
expect(overlay).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Integration Tests
|
||||
// ================================
|
||||
describe('Empty and Line Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
it('should render Line components with correct theme in Empty', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// In light mode, should use light gradient ID
|
||||
const lightGradients = container.querySelectorAll('#paint0_linear_1989_74474')
|
||||
expect(lightGradients.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should render Line components with dark theme in Empty', () => {
|
||||
mockTheme = 'dark'
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// In dark mode, should use dark gradient ID
|
||||
const darkGradients = container.querySelectorAll('#paint0_linear_6295_52176')
|
||||
expect(darkGradients.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should apply positioning classes to Line components', () => {
|
||||
const { container } = render(<Empty />)
|
||||
|
||||
// Check for Line positioning classes
|
||||
expect(container.querySelector('.right-\\[-1px\\]')).toBeInTheDocument()
|
||||
expect(container.querySelector('.left-\\[-1px\\]')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.rotate-90').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render complete Empty component structure', () => {
|
||||
const { container } = render(<Empty text="Test" lightCard className="test" locale="en-US" />)
|
||||
|
||||
// Container
|
||||
expect(container.querySelector('.test')).toBeInTheDocument()
|
||||
|
||||
// Placeholder cards
|
||||
expect(container.querySelectorAll('.h-\\[144px\\]').length).toBe(16)
|
||||
|
||||
// Icon container
|
||||
expect(container.querySelector('.h-14.w-14')).toBeInTheDocument()
|
||||
|
||||
// Line components (4) + Group icon (1) = 5 SVGs total
|
||||
expect(container.querySelectorAll('svg').length).toBe(5)
|
||||
|
||||
// Text
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
|
||||
// No overlay for lightCard
|
||||
expect(container.querySelector('.bg-marketplace-plugin-empty')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,734 @@
|
|||
import type { MarketplaceContextValue } from '../context'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SortDropdown from './index'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
const mockTranslation = vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'plugin.marketplace.sortBy': 'Sort by',
|
||||
'plugin.marketplace.sortOption.mostPopular': 'Most Popular',
|
||||
'plugin.marketplace.sortOption.recentlyUpdated': 'Recently Updated',
|
||||
'plugin.marketplace.sortOption.newlyReleased': 'Newly Released',
|
||||
'plugin.marketplace.sortOption.firstReleased': 'First Released',
|
||||
}
|
||||
return translations[key] || key
|
||||
})
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
t: mockTranslation,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock marketplace context with controllable values
|
||||
let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const mockHandleSortChange = vi.fn()
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => {
|
||||
const contextValue = {
|
||||
sort: mockSort,
|
||||
handleSortChange: mockHandleSortChange,
|
||||
} as unknown as MarketplaceContextValue
|
||||
return selector(contextValue)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock portal component with controllable open state
|
||||
let mockPortalOpenState = false
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open, onOpenChange }: {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
mockPortalOpenState = open
|
||||
return (
|
||||
<div data-testid="portal-wrapper" data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
|
||||
// Match actual behavior: only render when portal is open
|
||||
if (!mockPortalOpenState)
|
||||
return null
|
||||
return <div data-testid="portal-content">{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Test Factory Functions
|
||||
// ================================
|
||||
|
||||
type SortOption = {
|
||||
value: string
|
||||
order: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const createSortOptions = (): SortOption[] => [
|
||||
{ value: 'install_count', order: 'DESC', text: 'Most Popular' },
|
||||
{ value: 'version_updated_at', order: 'DESC', text: 'Recently Updated' },
|
||||
{ value: 'created_at', order: 'DESC', text: 'Newly Released' },
|
||||
{ value: 'created_at', order: 'ASC', text: 'First Released' },
|
||||
]
|
||||
|
||||
// ================================
|
||||
// SortDropdown Component Tests
|
||||
// ================================
|
||||
describe('SortDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
mockPortalOpenState = false
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render sort by label', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render selected option text', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render arrow down icon', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const arrowIcon = container.querySelector('.h-4.w-4.text-text-tertiary')
|
||||
expect(arrowIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger element with correct styles', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const trigger = container.querySelector('.cursor-pointer')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
expect(trigger).toHaveClass('h-8', 'rounded-lg', 'bg-state-base-hover-alt')
|
||||
})
|
||||
|
||||
it('should not render dropdown content when closed', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Testing
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should accept locale prop', () => {
|
||||
render(<SortDropdown locale="zh-CN" />)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call useMixedTranslation with provided locale', () => {
|
||||
render(<SortDropdown locale="ja-JP" />)
|
||||
|
||||
// Translation function should be called for labels
|
||||
expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortBy')
|
||||
})
|
||||
|
||||
it('should render without locale prop (undefined)', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty string locale', () => {
|
||||
render(<SortDropdown locale="" />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// State Management Tests
|
||||
// ================================
|
||||
describe('State Management', () => {
|
||||
it('should initialize with closed state', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const wrapper = screen.getByTestId('portal-wrapper')
|
||||
expect(wrapper).toHaveAttribute('data-open', 'false')
|
||||
})
|
||||
|
||||
it('should display correct selected option for install_count DESC', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for version_updated_at DESC', () => {
|
||||
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Recently Updated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for created_at DESC', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Newly Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for created_at ASC', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle open state when trigger clicked', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// After click, portal content should be visible
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close dropdown when trigger clicked again', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
|
||||
// Open
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
|
||||
// Close
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should open dropdown on trigger click', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all sort options when open', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
|
||||
expect(within(content).getByText('Recently Updated')).toBeInTheDocument()
|
||||
expect(within(content).getByText('Newly Released')).toBeInTheDocument()
|
||||
expect(within(content).getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleSortChange when option clicked', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Click on "Recently Updated"
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'version_updated_at',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for Most Popular', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Most Popular'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for Newly Released', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Newly Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for First Released', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'ASC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow selecting currently selected option', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Most Popular'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should support userEvent for trigger click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
await user.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Check Icon Tests
|
||||
// ================================
|
||||
describe('Check Icon', () => {
|
||||
it('should show check icon for selected option', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Check icon should be present in the dropdown
|
||||
const checkIcon = container.querySelector('.text-text-accent')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show check icon only for matching sortBy AND sortOrder', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
|
||||
// "Newly Released" (created_at DESC) should have check icon
|
||||
// "First Released" (created_at ASC) should NOT have check icon
|
||||
expect(options.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should not show check icon for different sortOrder with same sortBy', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Only one check icon should be visible (for Newly Released, not First Released)
|
||||
const checkIcons = container.querySelectorAll('.text-text-accent')
|
||||
expect(checkIcons.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Dropdown Options Structure Tests
|
||||
// ================================
|
||||
describe('Dropdown Options Structure', () => {
|
||||
const sortOptions = createSortOptions()
|
||||
|
||||
it('should render 4 sort options', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
expect(options.length).toBe(4)
|
||||
})
|
||||
|
||||
it.each(sortOptions)('should render option: $text', ({ text }) => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText(text)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render options with unique keys', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
|
||||
// All options should be rendered (no key conflicts)
|
||||
expect(options.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should render dropdown container with correct styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const container = content.firstChild as HTMLElement
|
||||
expect(container).toHaveClass('rounded-xl', 'shadow-lg')
|
||||
})
|
||||
|
||||
it('should render option items with hover styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const option = content.querySelector('.cursor-pointer')
|
||||
expect(option).toHaveClass('hover:bg-components-panel-on-panel-item-bg-hover')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle unknown sortBy value gracefully', () => {
|
||||
mockSort = { sortBy: 'unknown_field', sortOrder: 'DESC' }
|
||||
|
||||
// This may cause an error or undefined behavior
|
||||
// Component uses find() which returns undefined for non-matching
|
||||
expect(() => render(<SortDropdown />)).toThrow()
|
||||
})
|
||||
|
||||
it('should handle empty sortBy value', () => {
|
||||
mockSort = { sortBy: '', sortOrder: 'DESC' }
|
||||
|
||||
expect(() => render(<SortDropdown />)).toThrow()
|
||||
})
|
||||
|
||||
it('should handle unknown sortOrder value', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'UNKNOWN' }
|
||||
|
||||
// No matching option, selectedOption will be undefined
|
||||
expect(() => render(<SortDropdown />)).toThrow()
|
||||
})
|
||||
|
||||
it('should render correctly when handleSortChange is a no-op', () => {
|
||||
mockHandleSortChange.mockImplementation(() => {})
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rapid toggle clicks', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
|
||||
// Rapid clicks
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Final state should be open (odd number of clicks)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple option selections', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
|
||||
// Click multiple options
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
fireEvent.click(within(content).getByText('Newly Released'))
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Context Integration Tests
|
||||
// ================================
|
||||
describe('Context Integration', () => {
|
||||
it('should read sort value from context', () => {
|
||||
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Recently Updated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call context handleSortChange on selection', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'ASC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update display when context sort changes', () => {
|
||||
const { rerender } = render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
|
||||
// Simulate context change
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
|
||||
rerender(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use selector pattern correctly', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Component should have called useMarketplaceContext with selector functions
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Accessibility Tests
|
||||
// ================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have cursor pointer on trigger', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const trigger = container.querySelector('.cursor-pointer')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have cursor pointer on options', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have visible focus indicators via hover styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const option = content.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover')
|
||||
expect(option).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Translation Tests
|
||||
// ================================
|
||||
describe('Translations', () => {
|
||||
it('should call translation for sortBy label', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortBy')
|
||||
})
|
||||
|
||||
it('should call translation for all sort options', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortOption.mostPopular')
|
||||
expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortOption.recentlyUpdated')
|
||||
expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortOption.newlyReleased')
|
||||
expect(mockTranslation).toHaveBeenCalledWith('plugin.marketplace.sortOption.firstReleased')
|
||||
})
|
||||
|
||||
it('should pass locale to useMixedTranslation', () => {
|
||||
render(<SortDropdown locale="pt-BR" />)
|
||||
|
||||
// Verify component renders with locale
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Portal Component Integration Tests
|
||||
// ================================
|
||||
describe('Portal Component Integration', () => {
|
||||
it('should pass open state to PortalToFollowElem', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const wrapper = screen.getByTestId('portal-wrapper')
|
||||
expect(wrapper).toHaveAttribute('data-open', 'false')
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
expect(wrapper).toHaveAttribute('data-open', 'true')
|
||||
})
|
||||
|
||||
it('should render trigger content inside PortalToFollowElemTrigger', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
expect(within(trigger).getByText('Sort by')).toBeInTheDocument()
|
||||
expect(within(trigger).getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render options inside PortalToFollowElemContent', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Visual Style Tests
|
||||
// ================================
|
||||
describe('Visual Styles', () => {
|
||||
it('should apply correct trigger container styles', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const triggerDiv = container.querySelector('.flex.h-8.cursor-pointer.items-center.rounded-lg')
|
||||
expect(triggerDiv).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply secondary text color to sort by label', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const label = container.querySelector('.text-text-secondary')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label?.textContent).toBe('Sort by')
|
||||
})
|
||||
|
||||
it('should apply primary text color to selected option', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const selected = container.querySelector('.text-text-primary.system-sm-medium')
|
||||
expect(selected).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply tertiary text color to arrow icon', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const arrow = container.querySelector('.text-text-tertiary')
|
||||
expect(arrow).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply accent text color to check icon when option selected', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const checkIcon = container.querySelector('.text-text-accent')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply blur backdrop to dropdown container', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const container = content.querySelector('.backdrop-blur-sm')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// All Sort Options Click Tests
|
||||
// ================================
|
||||
describe('All Sort Options Click Handlers', () => {
|
||||
const testCases = [
|
||||
{ text: 'Most Popular', sortBy: 'install_count', sortOrder: 'DESC' },
|
||||
{ text: 'Recently Updated', sortBy: 'version_updated_at', sortOrder: 'DESC' },
|
||||
{ text: 'Newly Released', sortBy: 'created_at', sortOrder: 'DESC' },
|
||||
{ text: 'First Released', sortBy: 'created_at', sortOrder: 'ASC' },
|
||||
]
|
||||
|
||||
it.each(testCases)(
|
||||
'should call handleSortChange with { sortBy: "$sortBy", sortOrder: "$sortOrder" } when clicking "$text"',
|
||||
({ text, sortBy, sortOrder }) => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText(text))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({ sortBy, sortOrder })
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DeleteConfirm } from './delete-confirm'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
const mockToast = vi.fn()
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDelete.mockImplementation((_id: string, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeleteConfirm', () => {
|
||||
it('should prevent deletion when workflows in use and input mismatch', () => {
|
||||
render(
|
||||
<DeleteConfirm
|
||||
isShow
|
||||
currentId="sub-1"
|
||||
currentName="Subscription One"
|
||||
workflowsInUse={2}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
|
||||
|
||||
expect(mockDelete).not.toHaveBeenCalled()
|
||||
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should allow deletion after matching input name', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DeleteConfirm
|
||||
isShow
|
||||
currentId="sub-1"
|
||||
currentName="Subscription One"
|
||||
workflowsInUse={1}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirmInputPlaceholder/),
|
||||
{ target: { value: 'Subscription One' } },
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('sub-1', expect.any(Object))
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should show error toast when delete fails', () => {
|
||||
mockDelete.mockImplementation((_id: string, options?: { onError?: (error: Error) => void }) => {
|
||||
options?.onError?.(new Error('network error'))
|
||||
})
|
||||
|
||||
render(
|
||||
<DeleteConfirm
|
||||
isShow
|
||||
currentId="sub-1"
|
||||
currentName="Subscription One"
|
||||
workflowsInUse={0}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'network error' }))
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { ApiKeyEditModal } from './apikey-edit-modal'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUpdate = vi.fn()
|
||||
const mockVerify = vi.fn()
|
||||
const mockToast = vi.fn()
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({
|
||||
detail: {
|
||||
id: 'detail-1',
|
||||
plugin_id: 'plugin-1',
|
||||
name: 'Plugin',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
provider: 'provider-1',
|
||||
declaration: {
|
||||
trigger: {
|
||||
subscription_constructor: {
|
||||
parameters: [],
|
||||
credentials_schema: [
|
||||
{
|
||||
name: 'api_key',
|
||||
type: 'secret',
|
||||
label: 'API Key',
|
||||
required: false,
|
||||
default: 'token',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useUpdateTriggerSubscription: () => ({ mutate: mockUpdate, isPending: false }),
|
||||
useVerifyTriggerSubscription: () => ({ mutate: mockVerify, isPending: false }),
|
||||
useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/base/toast')>()
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
},
|
||||
useToastContext: () => ({
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockVerify.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
mockUpdate.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiKeyEditModal', () => {
|
||||
it('should render verify step with encrypted hint and allow cancel', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<ApiKeyEditModal subscription={createSubscription()} onClose={onClose} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'pluginTrigger.modal.common.verify' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'pluginTrigger.modal.common.back' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(content => content.includes('common.provider.encrypted.front'))).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { ManualEditModal } from './manual-edit-modal'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUpdate = vi.fn()
|
||||
const mockToast = vi.fn()
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({
|
||||
detail: {
|
||||
id: 'detail-1',
|
||||
plugin_id: 'plugin-1',
|
||||
name: 'Plugin',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
provider: 'provider-1',
|
||||
declaration: { trigger: { subscription_schema: [] } },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useUpdateTriggerSubscription: () => ({ mutate: mockUpdate, isPending: false }),
|
||||
useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/base/toast')>()
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
},
|
||||
useToastContext: () => ({
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.Unauthorized,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpdate.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ManualEditModal', () => {
|
||||
it('should render title and allow cancel', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<ManualEditModal subscription={createSubscription()} onClose={onClose} />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should submit update with default values', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<ManualEditModal subscription={createSubscription()} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subscriptionId: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
properties: undefined,
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { OAuthEditModal } from './oauth-edit-modal'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUpdate = vi.fn()
|
||||
const mockToast = vi.fn()
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({
|
||||
detail: {
|
||||
id: 'detail-1',
|
||||
plugin_id: 'plugin-1',
|
||||
name: 'Plugin',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
provider: 'provider-1',
|
||||
declaration: { trigger: { subscription_constructor: { parameters: [] } } },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useUpdateTriggerSubscription: () => ({ mutate: mockUpdate, isPending: false }),
|
||||
useTriggerPluginDynamicOptions: () => ({ data: [], isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/base/toast')>()
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
},
|
||||
useToastContext: () => ({
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.Oauth2,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpdate.mockImplementation((_payload: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuthEditModal', () => {
|
||||
it('should render title and allow cancel', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<OAuthEditModal subscription={createSubscription()} onClose={onClose} />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should submit update with default values', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<OAuthEditModal subscription={createSubscription()} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subscriptionId: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
parameters: undefined,
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionList } from './index'
|
||||
import { SubscriptionListMode } from './types'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
let mockSubscriptionListError: Error | null = null
|
||||
let mockSubscriptionListState: {
|
||||
isLoading: boolean
|
||||
refetch: () => void
|
||||
subscriptions?: TriggerSubscription[]
|
||||
}
|
||||
|
||||
let mockPluginDetail: PluginDetail | undefined
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => {
|
||||
if (mockSubscriptionListError)
|
||||
throw mockSubscriptionListError
|
||||
return mockSubscriptionListState
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: (selector: (state: { detail: PluginDetail | undefined }) => PluginDetail | undefined) =>
|
||||
selector({ detail: mockPluginDetail }),
|
||||
}))
|
||||
|
||||
const mockInitiateOAuth = vi.fn()
|
||||
const mockDeleteSubscription = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }),
|
||||
useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }),
|
||||
useInitiateTriggerOAuth: () => ({ mutate: mockInitiateOAuth }),
|
||||
useDeleteTriggerSubscription: () => ({ mutate: mockDeleteSubscription, isPending: false }),
|
||||
}))
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'plugin-detail-1',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-02T00:00:00Z',
|
||||
name: 'Test Plugin',
|
||||
plugin_id: 'plugin-id',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
declaration: {} as PluginDeclaration,
|
||||
installation_id: 'install-1',
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_unique_identifier: 'plugin-uid',
|
||||
source: 'marketplace' as PluginDetail['source'],
|
||||
meta: undefined,
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRefetch.mockReset()
|
||||
mockSubscriptionListError = null
|
||||
mockPluginDetail = undefined
|
||||
mockSubscriptionListState = {
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
subscriptions: [createSubscription()],
|
||||
}
|
||||
})
|
||||
|
||||
describe('SubscriptionList', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render list view by default', () => {
|
||||
render(<SubscriptionList />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.listNum/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state when subscriptions are loading', () => {
|
||||
mockSubscriptionListState = {
|
||||
...mockSubscriptionListState,
|
||||
isLoading: true,
|
||||
}
|
||||
|
||||
render(<SubscriptionList />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Subscription One')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render list view with plugin detail provided', () => {
|
||||
const pluginDetail = createPluginDetail()
|
||||
|
||||
render(<SubscriptionList pluginDetail={pluginDetail} />)
|
||||
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without list entries when subscriptions are empty', () => {
|
||||
mockSubscriptionListState = {
|
||||
...mockSubscriptionListState,
|
||||
subscriptions: [],
|
||||
}
|
||||
|
||||
render(<SubscriptionList />)
|
||||
|
||||
expect(screen.queryByText(/pluginTrigger\.subscription\.listNum/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Subscription One')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render selector view when mode is selector', () => {
|
||||
render(<SubscriptionList mode={SubscriptionListMode.SELECTOR} />)
|
||||
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should highlight the selected subscription when selectedId is provided', () => {
|
||||
render(
|
||||
<SubscriptionList
|
||||
mode={SubscriptionListMode.SELECTOR}
|
||||
selectedId="sub-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectedButton = screen.getByRole('button', { name: 'Subscription One' })
|
||||
const selectedRow = selectedButton.closest('div')
|
||||
|
||||
expect(selectedRow).toHaveClass('bg-state-base-hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onSelect with refetch callback when selecting a subscription', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<SubscriptionList
|
||||
mode={SubscriptionListMode.SELECTOR}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
const [selectedSubscription, callback] = onSelect.mock.calls[0]
|
||||
expect(selectedSubscription).toMatchObject({ id: 'sub-1', name: 'Subscription One' })
|
||||
expect(typeof callback).toBe('function')
|
||||
|
||||
callback?.()
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onSelect is undefined', () => {
|
||||
render(<SubscriptionList mode={SubscriptionListMode.SELECTOR} />)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should open delete confirm without triggering selection', () => {
|
||||
const onSelect = vi.fn()
|
||||
const { container } = render(
|
||||
<SubscriptionList
|
||||
mode={SubscriptionListMode.SELECTOR}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
const deleteButton = container.querySelector('.subscription-delete-btn')
|
||||
expect(deleteButton).toBeTruthy()
|
||||
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render error boundary fallback when an error occurs', () => {
|
||||
mockSubscriptionListError = new Error('boom')
|
||||
|
||||
render(<SubscriptionList />)
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionListView } from './list-view'
|
||||
|
||||
let mockSubscriptions: TriggerSubscription[] = []
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ subscriptions: mockSubscriptions }),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({ detail: undefined }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }),
|
||||
useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }),
|
||||
useInitiateTriggerOAuth: () => ({ mutate: vi.fn() }),
|
||||
}))
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockSubscriptions = []
|
||||
})
|
||||
|
||||
describe('SubscriptionListView', () => {
|
||||
it('should render subscription count and list when data exists', () => {
|
||||
mockSubscriptions = [createSubscription()]
|
||||
|
||||
render(<SubscriptionListView />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.listNum/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should omit count and list when subscriptions are empty', () => {
|
||||
render(<SubscriptionListView />)
|
||||
|
||||
expect(screen.queryByText(/pluginTrigger\.subscription\.listNum/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Subscription One')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply top border when showTopBorder is true', () => {
|
||||
const { container } = render(<SubscriptionListView showTopBorder />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('border-t')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
import type { TriggerLogEntity } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LogViewer from './log-viewer'
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
const mockWriteText = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (args: { type: string, message: string }) => mockToastNotify(args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ value }: { value: unknown }) => (
|
||||
<div data-testid="code-editor">{JSON.stringify(value)}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createLog = (overrides: Partial<TriggerLogEntity> = {}): TriggerLogEntity => ({
|
||||
id: 'log-1',
|
||||
endpoint: 'https://example.com',
|
||||
created_at: '2024-01-01T12:34:56Z',
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: 'https://example.com',
|
||||
headers: {
|
||||
'Host': 'example.com',
|
||||
'User-Agent': 'vitest',
|
||||
'Content-Length': '0',
|
||||
'Accept': '*/*',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
'X-Forwarded-Host': 'example.com',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'X-Github-Delivery': '1',
|
||||
'X-Github-Event': 'push',
|
||||
'X-Github-Hook-Id': '1',
|
||||
'X-Github-Hook-Installation-Target-Id': '1',
|
||||
'X-Github-Hook-Installation-Target-Type': 'repo',
|
||||
'Accept-Encoding': 'gzip',
|
||||
},
|
||||
data: 'payload=%7B%22foo%22%3A%22bar%22%7D',
|
||||
},
|
||||
response: {
|
||||
status_code: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': '2',
|
||||
},
|
||||
data: '{"ok":true}',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('LogViewer', () => {
|
||||
it('should render nothing when logs are empty', () => {
|
||||
const { container } = render(<LogViewer logs={[]} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render collapsed log entries', () => {
|
||||
render(<LogViewer logs={[createLog()]} />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.modal\.manual\.logs\.request/)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expand and render request/response payloads', () => {
|
||||
render(<LogViewer logs={[createLog()]} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }))
|
||||
|
||||
const editors = screen.getAllByTestId('code-editor')
|
||||
expect(editors.length).toBe(2)
|
||||
expect(editors[0]).toHaveTextContent('"foo":"bar"')
|
||||
})
|
||||
|
||||
it('should collapse expanded content when clicked again', () => {
|
||||
render(<LogViewer logs={[createLog()]} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByTestId('code-editor').length).toBe(2)
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error styling when response is an error', () => {
|
||||
render(<LogViewer logs={[createLog({ response: { ...createLog().response, status_code: 500 } })]} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })
|
||||
const wrapper = trigger.parentElement as HTMLElement
|
||||
|
||||
expect(wrapper).toHaveClass('border-state-destructive-border')
|
||||
})
|
||||
|
||||
it('should render raw response text and allow copying', () => {
|
||||
const rawLog = {
|
||||
...createLog(),
|
||||
response: 'plain response',
|
||||
} as unknown as TriggerLogEntity
|
||||
|
||||
render(<LogViewer logs={[rawLog]} />)
|
||||
|
||||
const toggleButton = screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ })
|
||||
fireEvent.click(toggleButton)
|
||||
|
||||
expect(screen.getByText('plain response')).toBeInTheDocument()
|
||||
|
||||
const copyButton = screen.getAllByRole('button').find(button => button !== toggleButton)
|
||||
expect(copyButton).toBeDefined()
|
||||
if (copyButton)
|
||||
fireEvent.click(copyButton)
|
||||
expect(mockWriteText).toHaveBeenCalledWith('plain response')
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
|
||||
})
|
||||
|
||||
it('should parse request data when it is raw JSON', () => {
|
||||
const log = createLog({ request: { ...createLog().request, data: '{\"hello\":1}' } })
|
||||
|
||||
render(<LogViewer logs={[log]} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }))
|
||||
|
||||
expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('"hello":1')
|
||||
})
|
||||
|
||||
it('should fallback to raw payload when decoding fails', () => {
|
||||
const log = createLog({ request: { ...createLog().request, data: 'payload=%E0%A4%A' } })
|
||||
|
||||
render(<LogViewer logs={[log]} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }))
|
||||
|
||||
expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('payload=%E0%A4%A')
|
||||
})
|
||||
|
||||
it('should keep request data string when JSON parsing fails', () => {
|
||||
const log = createLog({ request: { ...createLog().request, data: '{invalid}' } })
|
||||
|
||||
render(<LogViewer logs={[log]} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.modal\.manual\.logs\.request/ }))
|
||||
|
||||
expect(screen.getAllByTestId('code-editor')[0]).toHaveTextContent('{invalid}')
|
||||
})
|
||||
|
||||
it('should render multiple log entries with distinct indices', () => {
|
||||
const first = createLog({ id: 'log-1' })
|
||||
const second = createLog({ id: 'log-2', created_at: '2024-01-01T12:35:00Z' })
|
||||
|
||||
render(<LogViewer logs={[first, second]} />)
|
||||
|
||||
expect(screen.getByText(/#1/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/#2/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use index-based key when id is missing', () => {
|
||||
const log = { ...createLog(), id: '' }
|
||||
|
||||
render(<LogViewer logs={[log]} />)
|
||||
|
||||
expect(screen.getByText(/#1/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionSelectorEntry } from './selector-entry'
|
||||
|
||||
let mockSubscriptions: TriggerSubscription[] = []
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({
|
||||
subscriptions: mockSubscriptions,
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({ detail: undefined }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }),
|
||||
useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }),
|
||||
useInitiateTriggerOAuth: () => ({ mutate: vi.fn() }),
|
||||
useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSubscriptions = [createSubscription()]
|
||||
})
|
||||
|
||||
describe('SubscriptionSelectorEntry', () => {
|
||||
it('should render empty state label when no selection and closed', () => {
|
||||
render(<SubscriptionSelectorEntry selectedId={undefined} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('pluginTrigger.subscription.noSubscriptionSelected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder when open without selection', () => {
|
||||
render(<SubscriptionSelectorEntry selectedId={undefined} onSelect={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.getByText('pluginTrigger.subscription.selectPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show selected subscription name when id matches', () => {
|
||||
render(<SubscriptionSelectorEntry selectedId="sub-1" onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show removed label when selected subscription is missing', () => {
|
||||
render(<SubscriptionSelectorEntry selectedId="missing" onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('pluginTrigger.subscription.subscriptionRemoved')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelect and close the list after selection', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(<SubscriptionSelectorEntry selectedId={undefined} onSelect={onSelect} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'sub-1', name: 'Subscription One' }), expect.any(Function))
|
||||
expect(screen.queryByText('Subscription One')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { SubscriptionSelectorView } from './selector-view'
|
||||
|
||||
let mockSubscriptions: TriggerSubscription[] = []
|
||||
const mockRefetch = vi.fn()
|
||||
const mockDelete = vi.fn((_: string, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ subscriptions: mockSubscriptions, refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({ detail: undefined }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerProviderInfo: () => ({ data: { supported_creation_methods: [] } }),
|
||||
useTriggerOAuthConfig: () => ({ data: undefined, refetch: vi.fn() }),
|
||||
useInitiateTriggerOAuth: () => ({ mutate: vi.fn() }),
|
||||
useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSubscriptions = [createSubscription()]
|
||||
})
|
||||
|
||||
describe('SubscriptionSelectorView', () => {
|
||||
it('should render subscription list when data exists', () => {
|
||||
render(<SubscriptionSelectorView />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.listNum/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelect when a subscription is clicked', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(<SubscriptionSelectorView onSelect={onSelect} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'sub-1', name: 'Subscription One' }))
|
||||
})
|
||||
|
||||
it('should handle missing onSelect without crashing', () => {
|
||||
render(<SubscriptionSelectorView />)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Subscription One' }))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should highlight selected subscription row when selectedId matches', () => {
|
||||
render(<SubscriptionSelectorView selectedId="sub-1" />)
|
||||
|
||||
const selectedRow = screen.getByRole('button', { name: 'Subscription One' }).closest('div')
|
||||
expect(selectedRow).toHaveClass('bg-state-base-hover')
|
||||
})
|
||||
|
||||
it('should not highlight row when selectedId does not match', () => {
|
||||
render(<SubscriptionSelectorView selectedId="other-id" />)
|
||||
|
||||
const row = screen.getByRole('button', { name: 'Subscription One' }).closest('div')
|
||||
expect(row).not.toHaveClass('bg-state-base-hover')
|
||||
})
|
||||
|
||||
it('should omit header when there are no subscriptions', () => {
|
||||
mockSubscriptions = []
|
||||
|
||||
render(<SubscriptionSelectorView />)
|
||||
|
||||
expect(screen.queryByText(/pluginTrigger\.subscription\.listNum/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show delete confirm when delete action is clicked', () => {
|
||||
const { container } = render(<SubscriptionSelectorView />)
|
||||
|
||||
const deleteButton = container.querySelector('.subscription-delete-btn')
|
||||
expect(deleteButton).toBeTruthy()
|
||||
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should request selection reset after confirming delete', () => {
|
||||
const onSelect = vi.fn()
|
||||
const { container } = render(<SubscriptionSelectorView onSelect={onSelect} />)
|
||||
|
||||
const deleteButton = container.querySelector('.subscription-delete-btn')
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('sub-1', expect.any(Object))
|
||||
expect(onSelect).toHaveBeenCalledWith({ id: '', name: '' })
|
||||
})
|
||||
|
||||
it('should close delete confirm without selection reset on cancel', () => {
|
||||
const onSelect = vi.fn()
|
||||
const { container } = render(<SubscriptionSelectorView onSelect={onSelect} />)
|
||||
|
||||
const deleteButton = container.querySelector('.subscription-delete-btn')
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/ }))
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import SubscriptionCard from './subscription-card'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
vi.mock('./use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ refetch: mockRefetch }),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
usePluginStore: () => ({
|
||||
detail: {
|
||||
id: 'detail-1',
|
||||
plugin_id: 'plugin-1',
|
||||
name: 'Plugin',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
provider: 'provider-1',
|
||||
declaration: { trigger: { subscription_constructor: { parameters: [], credentials_schema: [] } } },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useUpdateTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useVerifyTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
|
||||
id: 'sub-1',
|
||||
name: 'Subscription One',
|
||||
provider: 'provider-1',
|
||||
credential_type: TriggerCredentialTypeEnum.ApiKey,
|
||||
credentials: {},
|
||||
endpoint: 'https://example.com',
|
||||
parameters: {},
|
||||
properties: {},
|
||||
workflows_in_use: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('SubscriptionCard', () => {
|
||||
it('should render subscription name and endpoint', () => {
|
||||
render(<SubscriptionCard data={createSubscription()} />)
|
||||
|
||||
expect(screen.getByText('Subscription One')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render used-by text when workflows are present', () => {
|
||||
render(<SubscriptionCard data={createSubscription({ workflows_in_use: 2 })} />)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.usedByNum/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open delete confirmation when delete action is clicked', () => {
|
||||
const { container } = render(<SubscriptionCard data={createSubscription()} />)
|
||||
|
||||
const deleteButton = container.querySelector('.subscription-delete-btn')
|
||||
expect(deleteButton).toBeTruthy()
|
||||
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open edit modal when edit action is clicked', () => {
|
||||
const { container } = render(<SubscriptionCard data={createSubscription()} />)
|
||||
|
||||
const actionButtons = container.querySelectorAll('button')
|
||||
const editButton = actionButtons[0]
|
||||
|
||||
fireEvent.click(editButton)
|
||||
|
||||
expect(screen.getByText(/pluginTrigger\.subscription\.list\.item\.actions\.edit\.title/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import type { SimpleDetail } from '../store'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSubscriptionList } from './use-subscription-list'
|
||||
|
||||
let mockDetail: SimpleDetail | undefined
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
const mockTriggerSubscriptions = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerSubscriptions: (...args: unknown[]) => mockTriggerSubscriptions(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) =>
|
||||
selector({ detail: mockDetail }),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDetail = undefined
|
||||
mockTriggerSubscriptions.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSubscriptionList', () => {
|
||||
it('should request subscriptions with provider from store', () => {
|
||||
mockDetail = {
|
||||
id: 'detail-1',
|
||||
plugin_id: 'plugin-1',
|
||||
name: 'Plugin',
|
||||
plugin_unique_identifier: 'plugin-uid',
|
||||
provider: 'test-provider',
|
||||
declaration: {},
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useSubscriptionList())
|
||||
|
||||
expect(mockTriggerSubscriptions).toHaveBeenCalledWith('test-provider')
|
||||
expect(result.current.detail).toEqual(mockDetail)
|
||||
})
|
||||
|
||||
it('should request subscriptions with empty provider when detail is missing', () => {
|
||||
const { result } = renderHook(() => useSubscriptionList())
|
||||
|
||||
expect(mockTriggerSubscriptions).toHaveBeenCalledWith('')
|
||||
expect(result.current.detail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return data from trigger subscription hook', () => {
|
||||
mockTriggerSubscriptions.mockReturnValue({
|
||||
data: [{ id: 'sub-1' }],
|
||||
isLoading: true,
|
||||
refetch: mockRefetch,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSubscriptionList())
|
||||
|
||||
expect(result.current.subscriptions).toEqual([{ id: 'sub-1' }])
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
expect(result.current.refetch).toBe(mockRefetch)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue