mirror of https://github.com/langgenius/dify.git
test: add comprehensive tests for plugin authentication components (#30094)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
parent
2c919efa69
commit
9a6b4147bc
|
|
@ -225,6 +225,7 @@ const AddOAuthButton = ({
|
|||
>
|
||||
</div>
|
||||
<div
|
||||
data-testid="oauth-settings-button"
|
||||
className={cn(
|
||||
'flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg hover:bg-components-button-primary-bg-hover',
|
||||
buttonRightClassName,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,786 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import type { PluginPayload } from '../types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory } from '../types'
|
||||
import Authorize from './index'
|
||||
|
||||
// Create a wrapper with QueryClientProvider for real component testing
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const testQueryClient = createTestQueryClient()
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock API hooks - only mock network-related hooks
|
||||
const mockGetPluginOAuthClientSchema = vi.fn()
|
||||
|
||||
vi.mock('../hooks/use-credential', () => ({
|
||||
useGetPluginOAuthUrlHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ authorization_url: '' }),
|
||||
}),
|
||||
useGetPluginOAuthClientSchemaHook: () => ({
|
||||
data: mockGetPluginOAuthClientSchema(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useSetPluginOAuthCustomClientHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useDeletePluginOAuthCustomClientHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useInvalidPluginOAuthClientSchemaHook: () => vi.fn(),
|
||||
useAddPluginCredentialHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useUpdatePluginCredentialHook: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useGetPluginCredentialSchemaHook: () => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock openOAuthPopup - window operations
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock service/use-triggers - API service
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerPluginDynamicOptions: () => ({
|
||||
data: { options: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
useTriggerPluginDynamicOptionsInfo: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
useInvalidTriggerDynamicOptions: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// Factory function for creating test PluginPayload
|
||||
const createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Authorize', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetPluginOAuthClientSchema.mockReturnValue({
|
||||
schema: [],
|
||||
is_oauth_custom_client_enabled: false,
|
||||
is_system_oauth_params_exists: false,
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render nothing when canOAuth and canApiKey are both false/undefined', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// No buttons should be rendered
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
// Container should only have wrapper element
|
||||
expect(container.querySelector('.flex')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only OAuth button when canOAuth is true and canApiKey is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// OAuth button should exist (either configured or setup button)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only API Key button when canApiKey is true and canOAuth is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both OAuth and API Key buttons when both are true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render divider when showDivider is true and both buttons are shown', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
showDivider={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when showDivider is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
showDivider={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when only one button type is shown', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
showDivider={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider by default (showDivider defaults to true)', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
describe('Props Testing', () => {
|
||||
describe('theme prop', () => {
|
||||
it('should render buttons with secondary theme variant when theme is secondary', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
theme="secondary"
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.className).toContain('btn-secondary')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled prop', () => {
|
||||
it('should disable OAuth button when disabled is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
disabled={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable API Key button when disabled is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
disabled={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not disable buttons when disabled is false', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
disabled={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('notAllowCustomCredential prop', () => {
|
||||
it('should disable OAuth button when notAllowCustomCredential is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable API Key button when notAllowCustomCredential is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should add opacity class when notAllowCustomCredential is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const wrappers = container.querySelectorAll('.opacity-50')
|
||||
expect(wrappers.length).toBe(2) // Both OAuth and API Key wrappers
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Button Text Variations ====================
|
||||
describe('Button Text Variations', () => {
|
||||
it('should show correct OAuth text based on canApiKey', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
// When canApiKey is false, should show "useOAuthAuth"
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('plugin.auth')
|
||||
|
||||
// When canApiKey is true, button text changes
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Memoization Dependencies ====================
|
||||
describe('Memoization and Re-rendering', () => {
|
||||
it('should maintain stable props across re-renders with same dependencies', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
theme="primary"
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const initialButtonCount = screen.getAllByRole('button').length
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
theme="primary"
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(initialButtonCount)
|
||||
})
|
||||
|
||||
it('should update when canApiKey changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should update when canOAuth changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should update button variant when theme changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
theme="primary"
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttonPrimary = screen.getByRole('button')
|
||||
// Primary theme with canOAuth=false should have primary variant
|
||||
expect(buttonPrimary.className).toContain('btn-primary')
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
theme="secondary"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button').className).toContain('btn-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined pluginPayload properties gracefully', () => {
|
||||
const pluginPayload: PluginPayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
providerType: undefined,
|
||||
detail: undefined,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle all auth categories', () => {
|
||||
const categories = [AuthCategory.tool, AuthCategory.datasource, AuthCategory.model, AuthCategory.trigger]
|
||||
|
||||
categories.forEach((category) => {
|
||||
const pluginPayload = createPluginPayload({ category })
|
||||
|
||||
const { unmount } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty string provider', () => {
|
||||
const pluginPayload = createPluginPayload({ provider: '' })
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle both disabled and notAllowCustomCredential together', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
disabled={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Component Memoization ====================
|
||||
describe('Component Memoization', () => {
|
||||
it('should be a memoized component (exported with memo)', async () => {
|
||||
const AuthorizeDefault = (await import('./index')).default
|
||||
expect(AuthorizeDefault).toBeDefined()
|
||||
// memo wrapped components are React elements with $$typeof
|
||||
expect(typeof AuthorizeDefault).toBe('object')
|
||||
})
|
||||
|
||||
it('should not re-render wrapper when notAllowCustomCredential stays the same', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { rerender, container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={false}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const initialOpacityElements = container.querySelectorAll('.opacity-50').length
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={false}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(initialOpacityElements)
|
||||
})
|
||||
|
||||
it('should update wrapper when notAllowCustomCredential changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender, container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(0)
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Integration with pluginPayload ====================
|
||||
describe('pluginPayload Integration', () => {
|
||||
it('should pass pluginPayload to OAuth button', () => {
|
||||
const pluginPayload = createPluginPayload({
|
||||
provider: 'special-provider',
|
||||
category: AuthCategory.model,
|
||||
})
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass pluginPayload to API Key button', () => {
|
||||
const pluginPayload = createPluginPayload({
|
||||
provider: 'another-provider',
|
||||
category: AuthCategory.datasource,
|
||||
})
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle pluginPayload with detail property', () => {
|
||||
const pluginPayload = createPluginPayload({
|
||||
detail: {
|
||||
plugin_id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
} as PluginPayload['detail'],
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Conditional Rendering Scenarios ====================
|
||||
describe('Conditional Rendering Scenarios', () => {
|
||||
it('should handle rapid prop changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={true} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
|
||||
rerender(<Authorize pluginPayload={pluginPayload} canOAuth={false} canApiKey={true} />)
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(<Authorize pluginPayload={pluginPayload} canOAuth={true} canApiKey={false} />)
|
||||
expect(screen.getAllByRole('button').length).toBe(1)
|
||||
|
||||
rerender(<Authorize pluginPayload={pluginPayload} canOAuth={false} canApiKey={false} />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should correctly toggle divider visibility based on button combinations', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
showDivider={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={false}
|
||||
showDivider={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
canApiKey={true}
|
||||
showDivider={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Accessibility ====================
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible button elements', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should indicate disabled state for accessibility', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
canApiKey={true}
|
||||
disabled={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,937 @@
|
|||
import type { MetaData, PluginCategoryEnum } from '../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
// ==================== Imports (after mocks) ====================
|
||||
|
||||
import { PluginSource } from '../types'
|
||||
import Action from './action'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
// Use vi.hoisted to define mock functions that can be referenced in vi.mock
|
||||
const {
|
||||
mockUninstallPlugin,
|
||||
mockFetchReleases,
|
||||
mockCheckForUpdates,
|
||||
mockSetShowUpdatePluginModal,
|
||||
mockInvalidateInstalledPluginList,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUninstallPlugin: vi.fn(),
|
||||
mockFetchReleases: vi.fn(),
|
||||
mockCheckForUpdates: vi.fn(),
|
||||
mockSetShowUpdatePluginModal: vi.fn(),
|
||||
mockInvalidateInstalledPluginList: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock uninstall plugin service
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uninstallPlugin: (id: string) => mockUninstallPlugin(id),
|
||||
}))
|
||||
|
||||
// Mock GitHub releases hook
|
||||
vi.mock('../install-plugin/hooks', () => ({
|
||||
useGitHubReleases: () => ({
|
||||
fetchReleases: mockFetchReleases,
|
||||
checkForUpdates: mockCheckForUpdates,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock modal context
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowUpdatePluginModal: mockSetShowUpdatePluginModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock invalidate installed plugin list
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
|
||||
}))
|
||||
|
||||
// Mock PluginInfo component - has complex dependencies (Modal, KeyValueItem)
|
||||
vi.mock('../plugin-page/plugin-info', () => ({
|
||||
default: ({ repository, release, packageName, onHide }: {
|
||||
repository: string
|
||||
release: string
|
||||
packageName: string
|
||||
onHide: () => void
|
||||
}) => (
|
||||
<div data-testid="plugin-info-modal" data-repo={repository} data-release={release} data-package={packageName}>
|
||||
<button data-testid="close-plugin-info" onClick={onHide}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Tooltip - uses PortalToFollowElem which requires complex floating UI setup
|
||||
// Simplified mock that just renders children with tooltip content accessible
|
||||
vi.mock('../../base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
||||
<div data-testid="tooltip" data-popup-content={popupContent}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Confirm - uses createPortal which has issues in test environment
|
||||
vi.mock('../../base/confirm', () => ({
|
||||
default: ({ isShow, title, content, onCancel, onConfirm, isLoading, isDisabled }: {
|
||||
isShow: boolean
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
isLoading: boolean
|
||||
isDisabled: boolean
|
||||
}) => {
|
||||
if (!isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="confirm-modal" data-loading={isLoading} data-disabled={isDisabled}>
|
||||
<div data-testid="confirm-title">{title}</div>
|
||||
<div data-testid="confirm-content">{content}</div>
|
||||
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isDisabled}>Confirm</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
type ActionProps = {
|
||||
author: string
|
||||
installationId: string
|
||||
pluginUniqueIdentifier: string
|
||||
pluginName: string
|
||||
category: PluginCategoryEnum
|
||||
usedInApps: number
|
||||
isShowFetchNewVersion: boolean
|
||||
isShowInfo: boolean
|
||||
isShowDelete: boolean
|
||||
onDelete: () => void
|
||||
meta?: MetaData
|
||||
}
|
||||
|
||||
const createActionProps = (overrides: Partial<ActionProps> = {}): ActionProps => ({
|
||||
author: 'test-author',
|
||||
installationId: 'install-123',
|
||||
pluginUniqueIdentifier: 'test-author/test-plugin@1.0.0',
|
||||
pluginName: 'test-plugin',
|
||||
category: 'tool' as PluginCategoryEnum,
|
||||
usedInApps: 5,
|
||||
isShowFetchNewVersion: false,
|
||||
isShowInfo: false,
|
||||
isShowDelete: true,
|
||||
onDelete: vi.fn(),
|
||||
meta: {
|
||||
repo: 'test-author/test-plugin',
|
||||
version: '1.0.0',
|
||||
package: 'test-plugin.difypkg',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
// Helper to find action buttons (real ActionButton component uses type="button")
|
||||
const getActionButtons = () => screen.getAllByRole('button')
|
||||
const queryActionButtons = () => screen.queryAllByRole('button')
|
||||
|
||||
describe('Action Component', () => {
|
||||
// Spy on Toast.notify - real component but we track calls
|
||||
let toastNotifySpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Spy on Toast.notify and mock implementation to avoid DOM side effects
|
||||
toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
mockUninstallPlugin.mockResolvedValue({ success: true })
|
||||
mockFetchReleases.mockResolvedValue([])
|
||||
mockCheckForUpdates.mockReturnValue({
|
||||
needUpdate: false,
|
||||
toastProps: { type: 'info', message: 'Up to date' },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
toastNotifySpy.mockRestore()
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render delete button when isShowDelete is true', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(getActionButtons()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should render fetch new version button when isShowFetchNewVersion is true', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowInfo: false,
|
||||
isShowDelete: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(getActionButtons()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should render info button when isShowInfo is true', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: false,
|
||||
isShowInfo: true,
|
||||
isShowDelete: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(getActionButtons()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should render all buttons when all flags are true', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowInfo: true,
|
||||
isShowDelete: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(getActionButtons()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render no buttons when all flags are false', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: false,
|
||||
isShowInfo: false,
|
||||
isShowDelete: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(queryActionButtons()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render tooltips for each button', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowInfo: true,
|
||||
isShowDelete: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
|
||||
// Assert
|
||||
const tooltips = screen.getAllByTestId('tooltip')
|
||||
expect(tooltips).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Delete Functionality Tests ====================
|
||||
describe('Delete Functionality', () => {
|
||||
it('should show delete confirm modal when delete button is clicked', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('confirm-title')).toHaveTextContent('plugin.action.delete')
|
||||
})
|
||||
|
||||
it('should display plugin name in delete confirm content', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
pluginName: 'my-awesome-plugin',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide confirm modal when cancel is clicked', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call uninstallPlugin when confirm is clicked', async () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
installationId: 'install-456',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-456')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onDelete callback after successful uninstall', async () => {
|
||||
// Arrange
|
||||
mockUninstallPlugin.mockResolvedValue({ success: true })
|
||||
const onDelete = vi.fn()
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
onDelete,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(onDelete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onDelete if uninstall fails', async () => {
|
||||
// Arrange
|
||||
mockUninstallPlugin.mockResolvedValue({ success: false })
|
||||
const onDelete = vi.fn()
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
onDelete,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalled()
|
||||
})
|
||||
expect(onDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle uninstall error gracefully', async () => {
|
||||
// Arrange
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockUninstallPlugin.mockRejectedValue(new Error('Network error'))
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(consoleError).toHaveBeenCalledWith('uninstallPlugin error', expect.any(Error))
|
||||
})
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('should show loading state during deletion', async () => {
|
||||
// Arrange
|
||||
let resolveUninstall: (value: { success: boolean }) => void
|
||||
mockUninstallPlugin.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveUninstall = resolve
|
||||
}),
|
||||
)
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// Assert - Loading state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true')
|
||||
})
|
||||
|
||||
// Resolve and check modal closes
|
||||
resolveUninstall!({ success: true })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Plugin Info Tests ====================
|
||||
describe('Plugin Info', () => {
|
||||
it('should show plugin info modal when info button is clicked', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowInfo: true,
|
||||
isShowDelete: false,
|
||||
isShowFetchNewVersion: false,
|
||||
meta: {
|
||||
repo: 'owner/repo-name',
|
||||
version: '2.0.0',
|
||||
package: 'my-package.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-repo', 'owner/repo-name')
|
||||
expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-release', '2.0.0')
|
||||
expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-package', 'my-package.difypkg')
|
||||
})
|
||||
|
||||
it('should hide plugin info modal when close is clicked', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowInfo: true,
|
||||
isShowDelete: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-plugin-info'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('plugin-info-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Check for Updates Tests ====================
|
||||
describe('Check for Updates', () => {
|
||||
it('should fetch releases when check for updates button is clicked', async () => {
|
||||
// Arrange
|
||||
mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
meta: {
|
||||
repo: 'owner/repo',
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo')
|
||||
})
|
||||
})
|
||||
|
||||
it('should use author and pluginName as fallback for empty repo parts', async () => {
|
||||
// Arrange
|
||||
mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
author: 'fallback-author',
|
||||
pluginName: 'fallback-plugin',
|
||||
meta: {
|
||||
repo: '/', // Results in empty parts after split
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockFetchReleases).toHaveBeenCalledWith('fallback-author', 'fallback-plugin')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not proceed if no releases are fetched', async () => {
|
||||
// Arrange
|
||||
mockFetchReleases.mockResolvedValue([])
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockFetchReleases).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockCheckForUpdates).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show toast notification after checking for updates', async () => {
|
||||
// Arrange
|
||||
mockFetchReleases.mockResolvedValue([{ version: '2.0.0' }])
|
||||
mockCheckForUpdates.mockReturnValue({
|
||||
needUpdate: false,
|
||||
toastProps: { type: 'success', message: 'Already up to date' },
|
||||
})
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert - Toast.notify is called with the toast props
|
||||
await waitFor(() => {
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith({ type: 'success', message: 'Already up to date' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should show update modal when update is available', async () => {
|
||||
// Arrange
|
||||
const releases = [{ version: '2.0.0' }]
|
||||
mockFetchReleases.mockResolvedValue(releases)
|
||||
mockCheckForUpdates.mockReturnValue({
|
||||
needUpdate: true,
|
||||
toastProps: { type: 'info', message: 'Update available' },
|
||||
})
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
pluginUniqueIdentifier: 'test-id',
|
||||
category: 'model' as PluginCategoryEnum,
|
||||
meta: {
|
||||
repo: 'owner/repo',
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowUpdatePluginModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
type: PluginSource.github,
|
||||
category: 'model',
|
||||
github: expect.objectContaining({
|
||||
originalPackageInfo: expect.objectContaining({
|
||||
id: 'test-id',
|
||||
repo: 'owner/repo',
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
releases,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call invalidateInstalledPluginList on save callback', async () => {
|
||||
// Arrange
|
||||
const releases = [{ version: '2.0.0' }]
|
||||
mockFetchReleases.mockResolvedValue(releases)
|
||||
mockCheckForUpdates.mockReturnValue({
|
||||
needUpdate: true,
|
||||
toastProps: { type: 'info', message: 'Update available' },
|
||||
})
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Wait for modal to be called
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Invoke the callback
|
||||
const call = mockSetShowUpdatePluginModal.mock.calls[0][0]
|
||||
call.onSaveCallback()
|
||||
|
||||
// Assert
|
||||
expect(mockInvalidateInstalledPluginList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should check updates with current version', async () => {
|
||||
// Arrange
|
||||
const releases = [{ version: '2.0.0' }, { version: '1.5.0' }]
|
||||
mockFetchReleases.mockResolvedValue(releases)
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
meta: {
|
||||
repo: 'owner/repo',
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockCheckForUpdates).toHaveBeenCalledWith(releases, '1.0.0')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Callback Stability Tests ====================
|
||||
describe('Callback Stability (useCallback)', () => {
|
||||
it('should have stable handleDelete callback with same dependencies', async () => {
|
||||
// Arrange
|
||||
mockUninstallPlugin.mockResolvedValue({ success: true })
|
||||
const onDelete = vi.fn()
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
onDelete,
|
||||
installationId: 'stable-install-id',
|
||||
})
|
||||
|
||||
// Act - First render and delete
|
||||
const { rerender } = render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id')
|
||||
})
|
||||
|
||||
// Re-render with same props
|
||||
mockUninstallPlugin.mockClear()
|
||||
rerender(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update handleDelete when installationId changes', async () => {
|
||||
// Arrange
|
||||
mockUninstallPlugin.mockResolvedValue({ success: true })
|
||||
const props1 = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
installationId: 'install-1',
|
||||
})
|
||||
const props2 = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
installationId: 'install-2',
|
||||
})
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<Action {...props1} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-1')
|
||||
})
|
||||
|
||||
mockUninstallPlugin.mockClear()
|
||||
rerender(<Action {...props2} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstallPlugin).toHaveBeenCalledWith('install-2')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update handleDelete when onDelete changes', async () => {
|
||||
// Arrange
|
||||
mockUninstallPlugin.mockResolvedValue({ success: true })
|
||||
const onDelete1 = vi.fn()
|
||||
const onDelete2 = vi.fn()
|
||||
const props1 = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
onDelete: onDelete1,
|
||||
})
|
||||
const props2 = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
onDelete: onDelete2,
|
||||
})
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<Action {...props1} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDelete1).toHaveBeenCalled()
|
||||
})
|
||||
expect(onDelete2).not.toHaveBeenCalled()
|
||||
|
||||
rerender(<Action {...props2} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDelete2).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined meta for info display', () => {
|
||||
// Arrange - meta is required for info, but test defensive behavior
|
||||
const props = createActionProps({
|
||||
isShowInfo: false,
|
||||
isShowDelete: true,
|
||||
isShowFetchNewVersion: false,
|
||||
meta: undefined,
|
||||
})
|
||||
|
||||
// Act & Assert - Should not crash
|
||||
expect(() => render(<Action {...props} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle empty repo string', async () => {
|
||||
// Arrange
|
||||
mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }])
|
||||
const props = createActionProps({
|
||||
isShowFetchNewVersion: true,
|
||||
isShowDelete: false,
|
||||
isShowInfo: false,
|
||||
author: 'fallback-owner',
|
||||
pluginName: 'fallback-repo',
|
||||
meta: {
|
||||
repo: '',
|
||||
version: '1.0.0',
|
||||
package: 'pkg.difypkg',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert - Should use author and pluginName as fallback
|
||||
await waitFor(() => {
|
||||
expect(mockFetchReleases).toHaveBeenCalledWith('fallback-owner', 'fallback-repo')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle concurrent delete requests gracefully', async () => {
|
||||
// Arrange
|
||||
let resolveFirst: (value: { success: boolean }) => void
|
||||
const firstPromise = new Promise<{ success: boolean }>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
mockUninstallPlugin.mockReturnValueOnce(firstPromise)
|
||||
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
|
||||
// The confirm button should be disabled during deletion
|
||||
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true')
|
||||
expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-disabled', 'true')
|
||||
|
||||
// Resolve the deletion
|
||||
resolveFirst!({ success: true })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle special characters in plugin name', () => {
|
||||
// Arrange
|
||||
const props = createActionProps({
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
pluginName: 'plugin-with-special@chars#123',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Action {...props} />)
|
||||
fireEvent.click(getActionButtons()[0])
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('plugin-with-special@chars#123')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== React.memo Tests ====================
|
||||
describe('React.memo Behavior', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// Assert
|
||||
expect(Action).toBeDefined()
|
||||
expect((Action as any).$$typeof?.toString()).toContain('Symbol')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Prop Variations ====================
|
||||
describe('Prop Variations', () => {
|
||||
it('should handle all category types', () => {
|
||||
// Arrange
|
||||
const categories = ['tool', 'model', 'extension', 'agent-strategy', 'datasource'] as PluginCategoryEnum[]
|
||||
|
||||
categories.forEach((category) => {
|
||||
const props = createActionProps({
|
||||
category,
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
expect(() => render(<Action {...props} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle different usedInApps values', () => {
|
||||
// Arrange
|
||||
const values = [0, 1, 5, 100]
|
||||
|
||||
values.forEach((usedInApps) => {
|
||||
const props = createActionProps({
|
||||
usedInApps,
|
||||
isShowDelete: true,
|
||||
isShowInfo: false,
|
||||
isShowFetchNewVersion: false,
|
||||
})
|
||||
expect(() => render(<Action {...props} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle combination of multiple action buttons', () => {
|
||||
// Arrange - Test various combinations
|
||||
const combinations = [
|
||||
{ isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: false },
|
||||
{ isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: false },
|
||||
{ isShowFetchNewVersion: false, isShowInfo: false, isShowDelete: true },
|
||||
{ isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: false },
|
||||
{ isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: true },
|
||||
{ isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: true },
|
||||
{ isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: true },
|
||||
]
|
||||
|
||||
combinations.forEach((flags) => {
|
||||
const props = createActionProps(flags)
|
||||
const expectedCount = [flags.isShowFetchNewVersion, flags.isShowInfo, flags.isShowDelete].filter(Boolean).length
|
||||
|
||||
const { unmount } = render(<Action {...props} />)
|
||||
const buttons = queryActionButtons()
|
||||
expect(buttons).toHaveLength(expectedCount)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,583 @@
|
|||
import type { FilterState } from '../filter-management'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defaultSystemFeatures, InstallationScope } from '@/types/feature'
|
||||
|
||||
// ==================== Imports (after mocks) ====================
|
||||
|
||||
import Empty from './index'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
// Use vi.hoisted to define ALL mock state and functions
|
||||
const {
|
||||
mockSetActiveTab,
|
||||
mockUseInstalledPluginList,
|
||||
mockState,
|
||||
stableT,
|
||||
} = vi.hoisted(() => {
|
||||
const state = {
|
||||
filters: {
|
||||
categories: [] as string[],
|
||||
tags: [] as string[],
|
||||
searchQuery: '',
|
||||
} as FilterState,
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: 'all' as const,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
} as Partial<SystemFeatures>,
|
||||
pluginList: { plugins: [] as Array<{ id: string }> } as { plugins: Array<{ id: string }> } | undefined,
|
||||
}
|
||||
// Stable t function to prevent infinite re-renders
|
||||
// The component's useEffect and useMemo depend on t
|
||||
const t = (key: string) => key
|
||||
return {
|
||||
mockSetActiveTab: vi.fn(),
|
||||
mockUseInstalledPluginList: vi.fn(() => ({ data: state.pluginList })),
|
||||
mockState: state,
|
||||
stableT: t,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock plugin page context
|
||||
vi.mock('../context', () => ({
|
||||
usePluginPageContext: (selector: (value: any) => any) => {
|
||||
const contextValue = {
|
||||
filters: mockState.filters,
|
||||
setActiveTab: mockSetActiveTab,
|
||||
}
|
||||
return selector(contextValue)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock global public store (Zustand store)
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: any) => any) => {
|
||||
return selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
...mockState.systemFeatures,
|
||||
},
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useInstalledPluginList hook
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: () => mockUseInstalledPluginList(),
|
||||
}))
|
||||
|
||||
// Mock InstallFromGitHub component
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({
|
||||
default: ({ onClose }: { onSuccess: () => void, onClose: () => void }) => (
|
||||
<div data-testid="install-from-github-modal">
|
||||
<button data-testid="github-modal-close" onClick={onClose}>Close</button>
|
||||
<button data-testid="github-modal-success">Success</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock InstallFromLocalPackage component
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({
|
||||
default: ({ file, onClose }: { file: File, onSuccess: () => void, onClose: () => void }) => (
|
||||
<div data-testid="install-from-local-modal" data-file-name={file.name}>
|
||||
<button data-testid="local-modal-close" onClick={onClose}>Close</button>
|
||||
<button data-testid="local-modal-success">Success</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Line component
|
||||
vi.mock('../../marketplace/empty/line', () => ({
|
||||
default: ({ className }: { className?: string }) => <div data-testid="line-component" className={className} />,
|
||||
}))
|
||||
|
||||
// Override react-i18next with stable t function reference to prevent infinite re-renders
|
||||
// The component's useEffect and useMemo depend on t, so it MUST be stable
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: stableT,
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
const resetMockState = () => {
|
||||
mockState.filters = { categories: [], tags: [], searchQuery: '' }
|
||||
mockState.systemFeatures = {
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
}
|
||||
mockState.pluginList = { plugins: [] }
|
||||
mockUseInstalledPluginList.mockReturnValue({ data: mockState.pluginList })
|
||||
}
|
||||
|
||||
const setMockFilters = (filters: Partial<FilterState>) => {
|
||||
mockState.filters = { ...mockState.filters, ...filters }
|
||||
}
|
||||
|
||||
const setMockSystemFeatures = (features: Partial<SystemFeatures>) => {
|
||||
mockState.systemFeatures = { ...mockState.systemFeatures, ...features }
|
||||
}
|
||||
|
||||
const setMockPluginList = (list: { plugins: Array<{ id: string }> } | undefined) => {
|
||||
mockState.pluginList = list
|
||||
mockUseInstalledPluginList.mockReturnValue({ data: list })
|
||||
}
|
||||
|
||||
const createMockFile = (name: string, type = 'application/octet-stream'): File => {
|
||||
return new File(['test'], name, { type })
|
||||
}
|
||||
|
||||
// Helper to wait for useEffect to complete (single tick)
|
||||
const flushEffects = async () => {
|
||||
await act(async () => {})
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('Empty Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetMockState()
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render basic structure correctly', async () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert - file input
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(fileInput).toBeInTheDocument()
|
||||
expect(fileInput.style.display).toBe('none')
|
||||
expect(fileInput.accept).toBe('.difypkg,.difybndl')
|
||||
|
||||
// Assert - skeleton cards (20 in the grid + 1 icon container)
|
||||
const skeletonCards = container.querySelectorAll('.rounded-xl.bg-components-card-bg')
|
||||
expect(skeletonCards.length).toBeGreaterThanOrEqual(20)
|
||||
|
||||
// Assert - group icon container
|
||||
const iconContainer = document.querySelector('.size-14')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
|
||||
// Assert - line components
|
||||
const lines = screen.getAllByTestId('line-component')
|
||||
expect(lines).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Text Display Tests (useMemo) ====================
|
||||
describe('Text Display (useMemo)', () => {
|
||||
it('should display "noInstalled" text when plugin list is empty', async () => {
|
||||
// Arrange
|
||||
setMockPluginList({ plugins: [] })
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('list.noInstalled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "notFound" text when filters are active with plugins', async () => {
|
||||
// Arrange
|
||||
setMockPluginList({ plugins: [{ id: 'plugin-1' }] })
|
||||
|
||||
// Test categories filter
|
||||
setMockFilters({ categories: ['model'] })
|
||||
const { rerender } = render(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getByText('list.notFound')).toBeInTheDocument()
|
||||
|
||||
// Test tags filter
|
||||
setMockFilters({ categories: [], tags: ['tag1'] })
|
||||
rerender(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getByText('list.notFound')).toBeInTheDocument()
|
||||
|
||||
// Test searchQuery filter
|
||||
setMockFilters({ tags: [], searchQuery: 'test query' })
|
||||
rerender(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getByText('list.notFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prioritize "noInstalled" over "notFound" when no plugins exist', async () => {
|
||||
// Arrange
|
||||
setMockFilters({ categories: ['model'], searchQuery: 'test' })
|
||||
setMockPluginList({ plugins: [] })
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('list.noInstalled')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Install Methods Tests (useEffect) ====================
|
||||
describe('Install Methods (useEffect)', () => {
|
||||
it('should render all three install methods when marketplace enabled and not restricted', async () => {
|
||||
// Arrange
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
expect(screen.getByText('source.marketplace')).toBeInTheDocument()
|
||||
expect(screen.getByText('source.github')).toBeInTheDocument()
|
||||
expect(screen.getByText('source.local')).toBeInTheDocument()
|
||||
|
||||
// Verify button order
|
||||
const buttonTexts = buttons.map(btn => btn.textContent)
|
||||
expect(buttonTexts[0]).toContain('source.marketplace')
|
||||
expect(buttonTexts[1]).toContain('source.github')
|
||||
expect(buttonTexts[2]).toContain('source.local')
|
||||
})
|
||||
|
||||
it('should render only marketplace method when restricted to marketplace only', async () => {
|
||||
// Arrange
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(screen.getByText('source.marketplace')).toBeInTheDocument()
|
||||
expect(screen.queryByText('source.github')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('source.local')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render github and local methods when marketplace is disabled', async () => {
|
||||
// Arrange
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: false,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(screen.queryByText('source.marketplace')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('source.github')).toBeInTheDocument()
|
||||
expect(screen.getByText('source.local')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render no methods when marketplace disabled and restricted', async () => {
|
||||
// Arrange
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: false,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
expect(buttons).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== User Interactions Tests ====================
|
||||
describe('User Interactions', () => {
|
||||
it('should call setActiveTab with "discover" when marketplace button is clicked', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('source.marketplace'))
|
||||
|
||||
// Assert
|
||||
expect(mockSetActiveTab).toHaveBeenCalledWith('discover')
|
||||
})
|
||||
|
||||
it('should open and close GitHub modal correctly', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert - initially no modal
|
||||
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Act - open modal
|
||||
fireEvent.click(screen.getByText('source.github'))
|
||||
|
||||
// Assert - modal is open
|
||||
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
||||
|
||||
// Act - close modal
|
||||
fireEvent.click(screen.getByTestId('github-modal-close'))
|
||||
|
||||
// Assert - modal is closed
|
||||
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger file input click when local button is clicked', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('source.local'))
|
||||
|
||||
// Assert
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open and close local modal when file is selected', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const mockFile = createMockFile('test-plugin.difypkg')
|
||||
|
||||
// Assert - initially no modal
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Act - select file
|
||||
Object.defineProperty(fileInput, 'files', { value: [mockFile], writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
|
||||
// Assert - modal is open with correct file
|
||||
expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-plugin.difypkg')
|
||||
|
||||
// Act - close modal
|
||||
fireEvent.click(screen.getByTestId('local-modal-close'))
|
||||
|
||||
// Assert - modal is closed
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open local modal when no file is selected', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
|
||||
// Act - trigger change with empty files
|
||||
Object.defineProperty(fileInput, 'files', { value: [], writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== State Management Tests ====================
|
||||
describe('State Management', () => {
|
||||
it('should maintain modal state correctly and allow reopening', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Act - Open, close, and reopen GitHub modal
|
||||
fireEvent.click(screen.getByText('source.github'))
|
||||
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-modal-close'))
|
||||
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('source.github'))
|
||||
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update selectedFile state when file is selected', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
|
||||
// Act - select .difypkg file
|
||||
Object.defineProperty(fileInput, 'files', { value: [createMockFile('my-plugin.difypkg')], writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'my-plugin.difypkg')
|
||||
|
||||
// Close and select .difybndl file
|
||||
fireEvent.click(screen.getByTestId('local-modal-close'))
|
||||
Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-bundle.difybndl')], writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-bundle.difybndl')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Side Effects Tests ====================
|
||||
describe('Side Effects', () => {
|
||||
it('should render correct install methods based on system features', async () => {
|
||||
// Test 1: All methods when marketplace enabled and not restricted
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { unmount: unmount1 } = render(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
unmount1()
|
||||
|
||||
// Test 2: Only marketplace when restricted
|
||||
setMockSystemFeatures({
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: true,
|
||||
},
|
||||
})
|
||||
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
expect(screen.getByText('source.marketplace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correct text based on plugin list and filters', async () => {
|
||||
// Test 1: noInstalled when plugin list is empty
|
||||
setMockPluginList({ plugins: [] })
|
||||
setMockFilters({ categories: [], tags: [], searchQuery: '' })
|
||||
|
||||
const { unmount: unmount1 } = render(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getByText('list.noInstalled')).toBeInTheDocument()
|
||||
unmount1()
|
||||
|
||||
// Test 2: notFound when filters are active with plugins
|
||||
setMockFilters({ categories: ['tool'] })
|
||||
setMockPluginList({ plugins: [{ id: 'plugin-1' }] })
|
||||
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
expect(screen.getByText('list.notFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined plugin data gracefully', () => {
|
||||
// Test undefined plugin list - component should render without error
|
||||
setMockPluginList(undefined)
|
||||
expect(() => render(<Empty />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle file input edge cases', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
|
||||
// Test undefined files
|
||||
Object.defineProperty(fileInput, 'files', { value: undefined, writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== React.memo Tests ====================
|
||||
describe('React.memo Behavior', () => {
|
||||
it('should be wrapped with React.memo and have displayName', () => {
|
||||
// Assert
|
||||
expect(Empty).toBeDefined()
|
||||
expect((Empty as any).$$typeof?.toString()).toContain('Symbol')
|
||||
expect((Empty as any).displayName || (Empty as any).type?.displayName).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Modal Callbacks Tests ====================
|
||||
describe('Modal Callbacks', () => {
|
||||
it('should handle modal onSuccess callbacks (noop)', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Test GitHub modal onSuccess
|
||||
fireEvent.click(screen.getByText('source.github'))
|
||||
fireEvent.click(screen.getByTestId('github-modal-success'))
|
||||
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
||||
|
||||
// Close GitHub modal and test Local modal onSuccess
|
||||
fireEvent.click(screen.getByTestId('github-modal-close'))
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-plugin.difypkg')], writable: true })
|
||||
fireEvent.change(fileInput)
|
||||
|
||||
fireEvent.click(screen.getByTestId('local-modal-success'))
|
||||
expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Conditional Modal Rendering ====================
|
||||
describe('Conditional Modal Rendering', () => {
|
||||
it('should only render one modal at a time and require file for local modal', async () => {
|
||||
// Arrange
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert - no modals initially
|
||||
expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Open GitHub modal - only GitHub modal visible
|
||||
fireEvent.click(screen.getByText('source.github'))
|
||||
expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Click local button - triggers file input, no modal yet (no file selected)
|
||||
fireEvent.click(screen.getByText('source.local'))
|
||||
// GitHub modal should still be visible, local modal requires file selection
|
||||
expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,702 @@
|
|||
import type { PluginDeclaration, PluginDetail } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, PluginSource } from '../../types'
|
||||
|
||||
// ==================== Imports (after mocks) ====================
|
||||
|
||||
import PluginList from './index'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
// Mock PluginItem component to avoid complex dependency chain
|
||||
vi.mock('../../plugin-item', () => ({
|
||||
default: ({ plugin }: { plugin: PluginDetail }) => (
|
||||
<div
|
||||
data-testid="plugin-item"
|
||||
data-plugin-id={plugin.plugin_id}
|
||||
data-plugin-name={plugin.name}
|
||||
>
|
||||
{plugin.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
/**
|
||||
* Factory function to create a PluginDeclaration with defaults
|
||||
*/
|
||||
const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||
plugin_unique_identifier: 'test-plugin-id',
|
||||
version: '1.0.0',
|
||||
author: 'test-author',
|
||||
icon: 'test-icon.png',
|
||||
icon_dark: 'test-icon-dark.png',
|
||||
name: 'test-plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
label: { en_US: 'Test Plugin' } as any,
|
||||
description: { en_US: 'Test plugin description' } as any,
|
||||
created_at: '2024-01-01',
|
||||
resource: null,
|
||||
plugins: null,
|
||||
verified: false,
|
||||
endpoint: {} as any,
|
||||
model: null,
|
||||
tags: [],
|
||||
agent_strategy: null,
|
||||
meta: {
|
||||
version: '1.0.0',
|
||||
minimum_dify_version: '0.5.0',
|
||||
},
|
||||
trigger: {} as any,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory function to create a PluginDetail with defaults
|
||||
*/
|
||||
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
||||
id: 'plugin-1',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
name: 'test-plugin',
|
||||
plugin_id: 'plugin-1',
|
||||
plugin_unique_identifier: 'test-author/test-plugin@1.0.0',
|
||||
declaration: createPluginDeclaration(),
|
||||
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: 'test-author/test-plugin@1.0.0',
|
||||
source: PluginSource.marketplace,
|
||||
meta: {
|
||||
repo: 'test-author/test-plugin',
|
||||
version: '1.0.0',
|
||||
package: 'test-plugin.difypkg',
|
||||
},
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Factory function to create a list of plugins
|
||||
*/
|
||||
const createPluginList = (count: number, baseOverrides: Partial<PluginDetail> = {}): PluginDetail[] => {
|
||||
return Array.from({ length: count }, (_, index) => createPluginDetail({
|
||||
id: `plugin-${index + 1}`,
|
||||
plugin_id: `plugin-${index + 1}`,
|
||||
name: `plugin-${index + 1}`,
|
||||
plugin_unique_identifier: `test-author/plugin-${index + 1}@1.0.0`,
|
||||
...baseOverrides,
|
||||
}))
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('PluginList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ==================== Rendering Tests ====================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const pluginList: PluginDetail[] = []
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render container with correct structure', () => {
|
||||
// Arrange
|
||||
const pluginList: PluginDetail[] = []
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('pb-3')
|
||||
|
||||
const gridDiv = outerDiv.firstChild as HTMLElement
|
||||
expect(gridDiv).toHaveClass('grid', 'grid-cols-2', 'gap-3')
|
||||
})
|
||||
|
||||
it('should render single plugin correctly', () => {
|
||||
// Arrange
|
||||
const pluginList = [createPluginDetail({ name: 'single-plugin' })]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(1)
|
||||
expect(pluginItems[0]).toHaveAttribute('data-plugin-name', 'single-plugin')
|
||||
})
|
||||
|
||||
it('should render multiple plugins correctly', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(5)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('should render plugins in correct order', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'first', name: 'First Plugin' }),
|
||||
createPluginDetail({ plugin_id: 'second', name: 'Second Plugin' }),
|
||||
createPluginDetail({ plugin_id: 'third', name: 'Third Plugin' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems[0]).toHaveAttribute('data-plugin-id', 'first')
|
||||
expect(pluginItems[1]).toHaveAttribute('data-plugin-id', 'second')
|
||||
expect(pluginItems[2]).toHaveAttribute('data-plugin-id', 'third')
|
||||
})
|
||||
|
||||
it('should pass plugin prop to each PluginItem', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'plugin-a', name: 'Plugin A' }),
|
||||
createPluginDetail({ plugin_id: 'plugin-b', name: 'Plugin B' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Plugin A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plugin B')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
describe('Props', () => {
|
||||
it('should accept empty pluginList array', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<PluginList pluginList={[]} />)
|
||||
|
||||
// Assert
|
||||
const gridDiv = container.querySelector('.grid')
|
||||
expect(gridDiv).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should handle pluginList with various categories', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
plugin_id: 'tool-plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }),
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'model-plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }),
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'extension-plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.extension }),
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle pluginList with various sources', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'marketplace-plugin', source: PluginSource.marketplace }),
|
||||
createPluginDetail({ plugin_id: 'github-plugin', source: PluginSource.github }),
|
||||
createPluginDetail({ plugin_id: 'local-plugin', source: PluginSource.local }),
|
||||
createPluginDetail({ plugin_id: 'debugging-plugin', source: PluginSource.debugging }),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty array', () => {
|
||||
// Arrange & Act
|
||||
render(<PluginList pluginList={[]} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large number of plugins', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(100)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('should handle plugins with duplicate plugin_ids (key warning scenario)', () => {
|
||||
// Arrange - Testing that the component uses plugin_id as key
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'unique-1', name: 'Plugin 1' }),
|
||||
createPluginDetail({ plugin_id: 'unique-2', name: 'Plugin 2' }),
|
||||
]
|
||||
|
||||
// Act & Assert - Should render without issues
|
||||
expect(() => render(<PluginList pluginList={pluginList} />)).not.toThrow()
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle plugins with special characters in names', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'special-1', name: 'Plugin <with> "special" & chars' }),
|
||||
createPluginDetail({ plugin_id: 'special-2', name: '日本語プラグイン' }),
|
||||
createPluginDetail({ plugin_id: 'special-3', name: 'Emoji Plugin 🔌' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const pluginItems = screen.getAllByTestId('plugin-item')
|
||||
expect(pluginItems).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle plugins with very long names', () => {
|
||||
// Arrange
|
||||
const longName = 'A'.repeat(500)
|
||||
const pluginList = [createPluginDetail({ name: longName })]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle plugin with minimal data', () => {
|
||||
// Arrange
|
||||
const minimalPlugin = createPluginDetail({
|
||||
name: '',
|
||||
plugin_id: 'minimal',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={[minimalPlugin]} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle plugins with undefined optional fields', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
plugin_id: 'no-meta',
|
||||
meta: undefined,
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Grid Layout Tests ====================
|
||||
describe('Grid Layout', () => {
|
||||
it('should render with 2-column grid', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(4)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const gridDiv = container.querySelector('.grid')
|
||||
expect(gridDiv).toHaveClass('grid-cols-2')
|
||||
})
|
||||
|
||||
it('should have proper gap between items', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(4)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const gridDiv = container.querySelector('.grid')
|
||||
expect(gridDiv).toHaveClass('gap-3')
|
||||
})
|
||||
|
||||
it('should have bottom padding on container', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(2)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('pb-3')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Re-render Tests ====================
|
||||
describe('Re-render Behavior', () => {
|
||||
it('should update when pluginList changes', () => {
|
||||
// Arrange
|
||||
const initialList = createPluginList(2)
|
||||
const updatedList = createPluginList(4)
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PluginList pluginList={initialList} />)
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
|
||||
|
||||
rerender(<PluginList pluginList={updatedList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('should handle pluginList update from non-empty to empty', () => {
|
||||
// Arrange
|
||||
const initialList = createPluginList(3)
|
||||
const emptyList: PluginDetail[] = []
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PluginList pluginList={initialList} />)
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(3)
|
||||
|
||||
rerender(<PluginList pluginList={emptyList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle pluginList update from empty to non-empty', () => {
|
||||
// Arrange
|
||||
const emptyList: PluginDetail[] = []
|
||||
const filledList = createPluginList(3)
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PluginList pluginList={emptyList} />)
|
||||
expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<PluginList pluginList={filledList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should update individual plugin data on re-render', () => {
|
||||
// Arrange
|
||||
const initialList = [createPluginDetail({ plugin_id: 'plugin-1', name: 'Original Name' })]
|
||||
const updatedList = [createPluginDetail({ plugin_id: 'plugin-1', name: 'Updated Name' })]
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PluginList pluginList={initialList} />)
|
||||
expect(screen.getByText('Original Name')).toBeInTheDocument()
|
||||
|
||||
rerender(<PluginList pluginList={updatedList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Updated Name')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Original Name')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Key Prop Tests ====================
|
||||
describe('Key Prop Behavior', () => {
|
||||
it('should use plugin_id as key for efficient re-renders', () => {
|
||||
// Arrange - Create plugins with unique plugin_ids
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'stable-key-1', name: 'Plugin 1' }),
|
||||
createPluginDetail({ plugin_id: 'stable-key-2', name: 'Plugin 2' }),
|
||||
createPluginDetail({ plugin_id: 'stable-key-3', name: 'Plugin 3' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Reorder the list
|
||||
const reorderedList = [pluginList[2], pluginList[0], pluginList[1]]
|
||||
rerender(<PluginList pluginList={reorderedList} />)
|
||||
|
||||
// Assert - All items should still be present
|
||||
const items = screen.getAllByTestId('plugin-item')
|
||||
expect(items).toHaveLength(3)
|
||||
expect(items[0]).toHaveAttribute('data-plugin-id', 'stable-key-3')
|
||||
expect(items[1]).toHaveAttribute('data-plugin-id', 'stable-key-1')
|
||||
expect(items[2]).toHaveAttribute('data-plugin-id', 'stable-key-2')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Plugin Status Variations ====================
|
||||
describe('Plugin Status Variations', () => {
|
||||
it('should render active plugins', () => {
|
||||
// Arrange
|
||||
const pluginList = [createPluginDetail({ status: 'active' })]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render deleted/deprecated plugins', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
status: 'deleted',
|
||||
deprecated_reason: 'No longer maintained',
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render mixed status plugins', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({ plugin_id: 'active-plugin', status: 'active' }),
|
||||
createPluginDetail({
|
||||
plugin_id: 'deprecated-plugin',
|
||||
status: 'deleted',
|
||||
deprecated_reason: 'Deprecated',
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Version Variations ====================
|
||||
describe('Version Variations', () => {
|
||||
it('should render plugins with same version as latest', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugins with outdated version', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
version: '1.0.0',
|
||||
latest_version: '2.0.0',
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('plugin-item')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Accessibility ====================
|
||||
describe('Accessibility', () => {
|
||||
it('should render as a semantic container', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(2)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert - The list is rendered as divs which is appropriate for a grid layout
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv.tagName).toBe('DIV')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Component Type ====================
|
||||
describe('Component Type', () => {
|
||||
it('should be a functional component', () => {
|
||||
// Assert
|
||||
expect(typeof PluginList).toBe('function')
|
||||
})
|
||||
|
||||
it('should accept pluginList as required prop', () => {
|
||||
// Arrange & Act - TypeScript ensures this at compile time
|
||||
// but we verify runtime behavior
|
||||
const pluginList = createPluginList(1)
|
||||
|
||||
// Assert
|
||||
expect(() => render(<PluginList pluginList={pluginList} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Mixed Content Tests ====================
|
||||
describe('Mixed Content', () => {
|
||||
it('should render plugins from different sources together', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
plugin_id: 'marketplace-1',
|
||||
name: 'Marketplace Plugin',
|
||||
source: PluginSource.marketplace,
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'github-1',
|
||||
name: 'GitHub Plugin',
|
||||
source: PluginSource.github,
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'local-1',
|
||||
name: 'Local Plugin',
|
||||
source: PluginSource.local,
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Marketplace Plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('GitHub Plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('Local Plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugins of different categories together', () => {
|
||||
// Arrange
|
||||
const pluginList = [
|
||||
createPluginDetail({
|
||||
plugin_id: 'tool-1',
|
||||
name: 'Tool Plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }),
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'model-1',
|
||||
name: 'Model Plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }),
|
||||
}),
|
||||
createPluginDetail({
|
||||
plugin_id: 'agent-1',
|
||||
name: 'Agent Plugin',
|
||||
declaration: createPluginDeclaration({ category: PluginCategoryEnum.agent }),
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Tool Plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('Model Plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent Plugin')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Boundary Tests ====================
|
||||
describe('Boundary Tests', () => {
|
||||
it('should handle single item list', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(1)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle two items (fills one row)', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(2)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle three items (partial second row)', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(3)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle odd number of items', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(7)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should handle even number of items', () => {
|
||||
// Arrange
|
||||
const pluginList = createPluginList(8)
|
||||
|
||||
// Act
|
||||
render(<PluginList pluginList={pluginList} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('plugin-item')).toHaveLength(8)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue