mirror of https://github.com/langgenius/dify.git
1793 lines
61 KiB
TypeScript
1793 lines
61 KiB
TypeScript
import type { AutoUpdateConfig } from './types'
|
|
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
import { fireEvent, render, screen } from '@testing-library/react'
|
|
import dayjs from 'dayjs'
|
|
import timezone from 'dayjs/plugin/timezone'
|
|
import utc from 'dayjs/plugin/utc'
|
|
import * as React from 'react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { PluginCategoryEnum, PluginSource } from '../../types'
|
|
import { defaultValue } from './config'
|
|
import AutoUpdateSetting from './index'
|
|
import NoDataPlaceholder from './no-data-placeholder'
|
|
import NoPluginSelected from './no-plugin-selected'
|
|
import PluginsPicker from './plugins-picker'
|
|
import PluginsSelected from './plugins-selected'
|
|
import StrategyPicker from './strategy-picker'
|
|
import ToolItem from './tool-item'
|
|
import ToolPicker from './tool-picker'
|
|
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from './types'
|
|
import {
|
|
convertLocalSecondsToUTCDaySeconds,
|
|
convertUTCDaySecondsToLocalSeconds,
|
|
dayjsToTimeOfDay,
|
|
timeOfDayToDayjs,
|
|
} from './utils'
|
|
|
|
// Setup dayjs plugins
|
|
dayjs.extend(utc)
|
|
dayjs.extend(timezone)
|
|
|
|
// ================================
|
|
// Mock External Dependencies Only
|
|
// ================================
|
|
|
|
// Mock react-i18next
|
|
vi.mock('react-i18next', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('react-i18next')>()
|
|
return {
|
|
...actual,
|
|
Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => {
|
|
if (i18nKey === 'autoUpdate.changeTimezone' && components?.setTimezone) {
|
|
return (
|
|
<span>
|
|
Change in
|
|
{components.setTimezone}
|
|
</span>
|
|
)
|
|
}
|
|
return <span>{i18nKey}</span>
|
|
},
|
|
useTranslation: () => ({
|
|
t: (key: string, options?: { ns?: string, num?: number }) => {
|
|
const translations: Record<string, string> = {
|
|
'autoUpdate.updateSettings': 'Update Settings',
|
|
'autoUpdate.automaticUpdates': 'Automatic Updates',
|
|
'autoUpdate.updateTime': 'Update Time',
|
|
'autoUpdate.specifyPluginsToUpdate': 'Specify Plugins to Update',
|
|
'autoUpdate.strategy.fixOnly.selectedDescription': 'Only apply bug fixes',
|
|
'autoUpdate.strategy.latest.selectedDescription': 'Always update to latest',
|
|
'autoUpdate.strategy.disabled.name': 'Disabled',
|
|
'autoUpdate.strategy.disabled.description': 'No automatic updates',
|
|
'autoUpdate.strategy.fixOnly.name': 'Bug Fixes Only',
|
|
'autoUpdate.strategy.fixOnly.description': 'Only apply bug fixes and patches',
|
|
'autoUpdate.strategy.latest.name': 'Latest Version',
|
|
'autoUpdate.strategy.latest.description': 'Always update to the latest version',
|
|
'autoUpdate.upgradeMode.all': 'All Plugins',
|
|
'autoUpdate.upgradeMode.exclude': 'Exclude Selected',
|
|
'autoUpdate.upgradeMode.partial': 'Selected Only',
|
|
'autoUpdate.excludeUpdate': `Excluding ${options?.num || 0} plugins`,
|
|
'autoUpdate.partialUPdate': `Updating ${options?.num || 0} plugins`,
|
|
'autoUpdate.operation.clearAll': 'Clear All',
|
|
'autoUpdate.operation.select': 'Select Plugins',
|
|
'autoUpdate.upgradeModePlaceholder.partial': 'Select plugins to update',
|
|
'autoUpdate.upgradeModePlaceholder.exclude': 'Select plugins to exclude',
|
|
'autoUpdate.noPluginPlaceholder.noInstalled': 'No plugins installed',
|
|
'autoUpdate.noPluginPlaceholder.noFound': 'No plugins found',
|
|
'category.all': 'All',
|
|
'category.models': 'Models',
|
|
'category.tools': 'Tools',
|
|
'category.agents': 'Agents',
|
|
'category.extensions': 'Extensions',
|
|
'category.datasources': 'Datasources',
|
|
'category.triggers': 'Triggers',
|
|
'category.bundles': 'Bundles',
|
|
'searchTools': 'Search tools...',
|
|
}
|
|
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
|
return translations[fullKey] || translations[key] || key
|
|
},
|
|
}),
|
|
}
|
|
})
|
|
|
|
// Mock app context
|
|
const mockTimezone = 'America/New_York'
|
|
vi.mock('@/context/app-context', () => ({
|
|
useAppContext: () => ({
|
|
userProfile: {
|
|
timezone: mockTimezone,
|
|
},
|
|
}),
|
|
}))
|
|
|
|
// Mock modal context
|
|
const mockSetShowAccountSettingModal = vi.fn()
|
|
vi.mock('@/context/modal-context', () => ({
|
|
useModalContextSelector: (selector: (s: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => typeof mockSetShowAccountSettingModal) => {
|
|
return selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal })
|
|
},
|
|
}))
|
|
|
|
// Mock i18n context
|
|
vi.mock('@/context/i18n', () => ({
|
|
useGetLanguage: () => 'en-US',
|
|
}))
|
|
|
|
// Mock plugins service
|
|
const mockPluginsData: { plugins: PluginDetail[] } = { plugins: [] }
|
|
vi.mock('@/service/use-plugins', () => ({
|
|
useInstalledPluginList: () => ({
|
|
data: mockPluginsData,
|
|
isLoading: false,
|
|
}),
|
|
}))
|
|
|
|
// Mock portal component for ToolPicker and StrategyPicker
|
|
let mockPortalOpen = false
|
|
let forcePortalContentVisible = false // Allow tests to force content visibility
|
|
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: {
|
|
children: React.ReactNode
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
}) => {
|
|
mockPortalOpen = open
|
|
return <div data-testid="portal-elem" data-open={open}>{children}</div>
|
|
},
|
|
PortalToFollowElemTrigger: ({ children, onClick, className }: {
|
|
children: React.ReactNode
|
|
onClick: (e: React.MouseEvent) => void
|
|
className?: string
|
|
}) => (
|
|
<div data-testid="portal-trigger" onClick={onClick} className={className}>
|
|
{children}
|
|
</div>
|
|
),
|
|
PortalToFollowElemContent: ({ children, className }: {
|
|
children: React.ReactNode
|
|
className?: string
|
|
}) => {
|
|
// Allow forcing content visibility for testing option selection
|
|
if (!mockPortalOpen && !forcePortalContentVisible)
|
|
return null
|
|
return <div data-testid="portal-content" className={className}>{children}</div>
|
|
},
|
|
}))
|
|
|
|
// Mock TimePicker component - simplified stateless mock
|
|
vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
|
|
default: ({ value, onChange, onClear, renderTrigger }: {
|
|
value: { format: (f: string) => string }
|
|
onChange: (v: unknown) => void
|
|
onClear: () => void
|
|
title?: string
|
|
renderTrigger: (params: { inputElem: React.ReactNode, onClick: () => void, isOpen: boolean }) => React.ReactNode
|
|
}) => {
|
|
const inputElem = <span data-testid="time-input">{value.format('HH:mm')}</span>
|
|
|
|
return (
|
|
<div data-testid="time-picker">
|
|
{renderTrigger({
|
|
inputElem,
|
|
onClick: () => {},
|
|
isOpen: false,
|
|
})}
|
|
<div data-testid="time-picker-dropdown">
|
|
<button
|
|
data-testid="time-picker-set"
|
|
onClick={() => {
|
|
onChange(dayjs().hour(10).minute(30))
|
|
}}
|
|
>
|
|
Set 10:30
|
|
</button>
|
|
<button
|
|
data-testid="time-picker-clear"
|
|
onClick={() => {
|
|
onClear()
|
|
}}
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
}))
|
|
|
|
// Mock utils from date-and-time-picker
|
|
vi.mock('@/app/components/base/date-and-time-picker/utils/dayjs', () => ({
|
|
convertTimezoneToOffsetStr: (tz: string) => {
|
|
if (tz === 'America/New_York')
|
|
return 'GMT-5'
|
|
if (tz === 'Asia/Shanghai')
|
|
return 'GMT+8'
|
|
return 'GMT+0'
|
|
},
|
|
}))
|
|
|
|
// Mock SearchBox component
|
|
vi.mock('@/app/components/plugins/marketplace/search-box', () => ({
|
|
default: ({ search, onSearchChange, tags: _tags, onTagsChange: _onTagsChange, placeholder }: {
|
|
search: string
|
|
onSearchChange: (v: string) => void
|
|
tags: string[]
|
|
onTagsChange: (v: string[]) => void
|
|
placeholder: string
|
|
}) => (
|
|
<div data-testid="search-box">
|
|
<input
|
|
data-testid="search-input"
|
|
value={search}
|
|
onChange={e => onSearchChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
/>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
// Mock Checkbox component
|
|
vi.mock('@/app/components/base/checkbox', () => ({
|
|
default: ({ checked, onCheck, className }: {
|
|
checked?: boolean
|
|
onCheck: () => void
|
|
className?: string
|
|
}) => (
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={onCheck}
|
|
className={className}
|
|
data-testid="checkbox"
|
|
/>
|
|
),
|
|
}))
|
|
|
|
// Mock Icon component
|
|
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
|
default: ({ size, src }: { size: string, src: string }) => (
|
|
<img data-testid="plugin-icon" data-size={size} src={src} alt="plugin icon" />
|
|
),
|
|
}))
|
|
|
|
// Mock icons
|
|
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
|
|
SearchMenu: ({ className }: { className?: string }) => <span data-testid="search-menu-icon" className={className}>🔍</span>,
|
|
}))
|
|
|
|
vi.mock('@/app/components/base/icons/src/vender/other', () => ({
|
|
Group: ({ className }: { className?: string }) => <span data-testid="group-icon" className={className}>📦</span>,
|
|
}))
|
|
|
|
// Mock PLUGIN_TYPE_SEARCH_MAP
|
|
vi.mock('../../marketplace/plugin-type-switch', () => ({
|
|
PLUGIN_TYPE_SEARCH_MAP: {
|
|
all: 'all',
|
|
model: 'model',
|
|
tool: 'tool',
|
|
agent: 'agent',
|
|
extension: 'extension',
|
|
datasource: 'datasource',
|
|
trigger: 'trigger',
|
|
bundle: 'bundle',
|
|
},
|
|
}))
|
|
|
|
// Mock i18n renderI18nObject
|
|
vi.mock('@/i18n-config', () => ({
|
|
renderI18nObject: (obj: Record<string, string>, lang: string) => obj[lang] || obj['en-US'] || '',
|
|
}))
|
|
|
|
// ================================
|
|
// Test Data Factories
|
|
// ================================
|
|
|
|
const createMockPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
|
plugin_unique_identifier: 'test-plugin-id',
|
|
version: '1.0.0',
|
|
author: 'test-author',
|
|
icon: 'test-icon.png',
|
|
name: 'Test Plugin',
|
|
category: PluginCategoryEnum.tool,
|
|
label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'],
|
|
description: { 'en-US': 'A test plugin' } as PluginDeclaration['description'],
|
|
created_at: '2024-01-01',
|
|
resource: {},
|
|
plugins: {},
|
|
verified: true,
|
|
endpoint: { settings: [], endpoints: [] },
|
|
model: {},
|
|
tags: ['tag1', 'tag2'],
|
|
agent_strategy: {},
|
|
meta: { version: '1.0.0' },
|
|
trigger: {
|
|
events: [],
|
|
identity: {
|
|
author: 'test',
|
|
name: 'test',
|
|
label: { 'en-US': 'Test' } as PluginDeclaration['label'],
|
|
description: { 'en-US': 'Test' } as PluginDeclaration['description'],
|
|
icon: 'test.png',
|
|
tags: [],
|
|
},
|
|
subscription_constructor: {
|
|
credentials_schema: [],
|
|
oauth_schema: { client_schema: [], credentials_schema: [] },
|
|
parameters: [],
|
|
},
|
|
subscription_schema: [],
|
|
},
|
|
...overrides,
|
|
})
|
|
|
|
const createMockPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
|
|
id: 'plugin-1',
|
|
created_at: '2024-01-01',
|
|
updated_at: '2024-01-01',
|
|
name: 'test-plugin',
|
|
plugin_id: 'test-plugin-id',
|
|
plugin_unique_identifier: 'test-plugin-unique',
|
|
declaration: createMockPluginDeclaration(),
|
|
installation_id: 'install-1',
|
|
tenant_id: 'tenant-1',
|
|
endpoints_setups: 0,
|
|
endpoints_active: 0,
|
|
version: '1.0.0',
|
|
latest_version: '1.1.0',
|
|
latest_unique_identifier: 'test-plugin-latest',
|
|
source: PluginSource.marketplace,
|
|
status: 'active',
|
|
deprecated_reason: '',
|
|
alternative_plugin_id: '',
|
|
...overrides,
|
|
})
|
|
|
|
const createMockAutoUpdateConfig = (overrides: Partial<AutoUpdateConfig> = {}): AutoUpdateConfig => ({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_time_of_day: 36000, // 10:00 UTC
|
|
upgrade_mode: AUTO_UPDATE_MODE.update_all,
|
|
exclude_plugins: [],
|
|
include_plugins: [],
|
|
...overrides,
|
|
})
|
|
|
|
// ================================
|
|
// Helper Functions
|
|
// ================================
|
|
|
|
const createQueryClient = () => new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
},
|
|
},
|
|
})
|
|
|
|
const renderWithQueryClient = (ui: React.ReactElement) => {
|
|
const queryClient = createQueryClient()
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
{ui}
|
|
</QueryClientProvider>,
|
|
)
|
|
}
|
|
|
|
// ================================
|
|
// Test Suites
|
|
// ================================
|
|
|
|
describe('auto-update-setting', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPortalOpen = false
|
|
forcePortalContentVisible = false
|
|
mockPluginsData.plugins = []
|
|
})
|
|
|
|
// ============================================================
|
|
// Types and Config Tests
|
|
// ============================================================
|
|
describe('types.ts', () => {
|
|
describe('AUTO_UPDATE_STRATEGY enum', () => {
|
|
it('should have correct values', () => {
|
|
expect(AUTO_UPDATE_STRATEGY.fixOnly).toBe('fix_only')
|
|
expect(AUTO_UPDATE_STRATEGY.disabled).toBe('disabled')
|
|
expect(AUTO_UPDATE_STRATEGY.latest).toBe('latest')
|
|
})
|
|
|
|
it('should contain exactly 3 strategies', () => {
|
|
const values = Object.values(AUTO_UPDATE_STRATEGY)
|
|
expect(values).toHaveLength(3)
|
|
})
|
|
})
|
|
|
|
describe('AUTO_UPDATE_MODE enum', () => {
|
|
it('should have correct values', () => {
|
|
expect(AUTO_UPDATE_MODE.partial).toBe('partial')
|
|
expect(AUTO_UPDATE_MODE.exclude).toBe('exclude')
|
|
expect(AUTO_UPDATE_MODE.update_all).toBe('all')
|
|
})
|
|
|
|
it('should contain exactly 3 modes', () => {
|
|
const values = Object.values(AUTO_UPDATE_MODE)
|
|
expect(values).toHaveLength(3)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('config.ts', () => {
|
|
describe('defaultValue', () => {
|
|
it('should have disabled strategy by default', () => {
|
|
expect(defaultValue.strategy_setting).toBe(AUTO_UPDATE_STRATEGY.disabled)
|
|
})
|
|
|
|
it('should have upgrade_time_of_day as 0', () => {
|
|
expect(defaultValue.upgrade_time_of_day).toBe(0)
|
|
})
|
|
|
|
it('should have update_all mode by default', () => {
|
|
expect(defaultValue.upgrade_mode).toBe(AUTO_UPDATE_MODE.update_all)
|
|
})
|
|
|
|
it('should have empty exclude_plugins array', () => {
|
|
expect(defaultValue.exclude_plugins).toEqual([])
|
|
})
|
|
|
|
it('should have empty include_plugins array', () => {
|
|
expect(defaultValue.include_plugins).toEqual([])
|
|
})
|
|
|
|
it('should be a complete AutoUpdateConfig object', () => {
|
|
const keys = Object.keys(defaultValue)
|
|
expect(keys).toContain('strategy_setting')
|
|
expect(keys).toContain('upgrade_time_of_day')
|
|
expect(keys).toContain('upgrade_mode')
|
|
expect(keys).toContain('exclude_plugins')
|
|
expect(keys).toContain('include_plugins')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Utils Tests (Extended coverage beyond utils.spec.ts)
|
|
// ============================================================
|
|
describe('utils.ts', () => {
|
|
describe('timeOfDayToDayjs', () => {
|
|
it('should convert 0 seconds to midnight', () => {
|
|
const result = timeOfDayToDayjs(0)
|
|
expect(result.hour()).toBe(0)
|
|
expect(result.minute()).toBe(0)
|
|
})
|
|
|
|
it('should convert 3600 seconds to 1:00', () => {
|
|
const result = timeOfDayToDayjs(3600)
|
|
expect(result.hour()).toBe(1)
|
|
expect(result.minute()).toBe(0)
|
|
})
|
|
|
|
it('should convert 36000 seconds to 10:00', () => {
|
|
const result = timeOfDayToDayjs(36000)
|
|
expect(result.hour()).toBe(10)
|
|
expect(result.minute()).toBe(0)
|
|
})
|
|
|
|
it('should convert 43200 seconds to 12:00 (noon)', () => {
|
|
const result = timeOfDayToDayjs(43200)
|
|
expect(result.hour()).toBe(12)
|
|
expect(result.minute()).toBe(0)
|
|
})
|
|
|
|
it('should convert 82800 seconds to 23:00', () => {
|
|
const result = timeOfDayToDayjs(82800)
|
|
expect(result.hour()).toBe(23)
|
|
expect(result.minute()).toBe(0)
|
|
})
|
|
|
|
it('should handle minutes correctly', () => {
|
|
const result = timeOfDayToDayjs(5400) // 1:30
|
|
expect(result.hour()).toBe(1)
|
|
expect(result.minute()).toBe(30)
|
|
})
|
|
|
|
it('should handle 15 minute intervals', () => {
|
|
expect(timeOfDayToDayjs(900).minute()).toBe(15)
|
|
expect(timeOfDayToDayjs(1800).minute()).toBe(30)
|
|
expect(timeOfDayToDayjs(2700).minute()).toBe(45)
|
|
})
|
|
})
|
|
|
|
describe('dayjsToTimeOfDay', () => {
|
|
it('should return 0 for undefined input', () => {
|
|
expect(dayjsToTimeOfDay(undefined)).toBe(0)
|
|
})
|
|
|
|
it('should convert midnight to 0', () => {
|
|
const midnight = dayjs().hour(0).minute(0)
|
|
expect(dayjsToTimeOfDay(midnight)).toBe(0)
|
|
})
|
|
|
|
it('should convert 1:00 to 3600', () => {
|
|
const time = dayjs().hour(1).minute(0)
|
|
expect(dayjsToTimeOfDay(time)).toBe(3600)
|
|
})
|
|
|
|
it('should convert 10:30 to 37800', () => {
|
|
const time = dayjs().hour(10).minute(30)
|
|
expect(dayjsToTimeOfDay(time)).toBe(37800)
|
|
})
|
|
|
|
it('should convert 23:59 to 86340', () => {
|
|
const time = dayjs().hour(23).minute(59)
|
|
expect(dayjsToTimeOfDay(time)).toBe(86340)
|
|
})
|
|
})
|
|
|
|
describe('convertLocalSecondsToUTCDaySeconds', () => {
|
|
it('should convert local midnight to UTC for positive offset timezone', () => {
|
|
// Shanghai is UTC+8, local midnight should be 16:00 UTC previous day
|
|
const result = convertLocalSecondsToUTCDaySeconds(0, 'Asia/Shanghai')
|
|
expect(result).toBe((24 - 8) * 3600)
|
|
})
|
|
|
|
it('should handle negative offset timezone', () => {
|
|
// New York is UTC-5 (or -4 during DST), local midnight should be 5:00 UTC
|
|
const result = convertLocalSecondsToUTCDaySeconds(0, 'America/New_York')
|
|
// Result depends on DST, but should be in valid range
|
|
expect(result).toBeGreaterThanOrEqual(0)
|
|
expect(result).toBeLessThan(86400)
|
|
})
|
|
|
|
it('should be reversible with convertUTCDaySecondsToLocalSeconds', () => {
|
|
const localSeconds = 36000 // 10:00 local
|
|
const utcSeconds = convertLocalSecondsToUTCDaySeconds(localSeconds, 'Asia/Shanghai')
|
|
const backToLocal = convertUTCDaySecondsToLocalSeconds(utcSeconds, 'Asia/Shanghai')
|
|
expect(backToLocal).toBe(localSeconds)
|
|
})
|
|
})
|
|
|
|
describe('convertUTCDaySecondsToLocalSeconds', () => {
|
|
it('should convert UTC midnight to local time for positive offset timezone', () => {
|
|
// UTC midnight in Shanghai (UTC+8) is 8:00 local
|
|
const result = convertUTCDaySecondsToLocalSeconds(0, 'Asia/Shanghai')
|
|
expect(result).toBe(8 * 3600)
|
|
})
|
|
|
|
it('should handle edge cases near day boundaries', () => {
|
|
// UTC 23:00 in Shanghai is 7:00 next day
|
|
const result = convertUTCDaySecondsToLocalSeconds(23 * 3600, 'Asia/Shanghai')
|
|
expect(result).toBeGreaterThanOrEqual(0)
|
|
expect(result).toBeLessThan(86400)
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// NoDataPlaceholder Component Tests
|
|
// ============================================================
|
|
describe('NoDataPlaceholder (no-data-placeholder.tsx)', () => {
|
|
describe('Rendering', () => {
|
|
it('should render with noPlugins=true showing group icon', () => {
|
|
// Act
|
|
render(<NoDataPlaceholder className="test-class" noPlugins={true} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('group-icon')).toBeInTheDocument()
|
|
expect(screen.getByText('No plugins installed')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render with noPlugins=false showing search icon', () => {
|
|
// Act
|
|
render(<NoDataPlaceholder className="test-class" noPlugins={false} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('search-menu-icon')).toBeInTheDocument()
|
|
expect(screen.getByText('No plugins found')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render with noPlugins=undefined (default) showing search icon', () => {
|
|
// Act
|
|
render(<NoDataPlaceholder className="test-class" />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('search-menu-icon')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should apply className prop', () => {
|
|
// Act
|
|
const { container } = render(<NoDataPlaceholder className="custom-height" />)
|
|
|
|
// Assert
|
|
expect(container.firstChild).toHaveClass('custom-height')
|
|
})
|
|
})
|
|
|
|
describe('Component Memoization', () => {
|
|
it('should be memoized with React.memo', () => {
|
|
expect(NoDataPlaceholder).toBeDefined()
|
|
expect((NoDataPlaceholder as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// NoPluginSelected Component Tests
|
|
// ============================================================
|
|
describe('NoPluginSelected (no-plugin-selected.tsx)', () => {
|
|
describe('Rendering', () => {
|
|
it('should render partial mode placeholder', () => {
|
|
// Act
|
|
render(<NoPluginSelected updateMode={AUTO_UPDATE_MODE.partial} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Select plugins to update')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render exclude mode placeholder', () => {
|
|
// Act
|
|
render(<NoPluginSelected updateMode={AUTO_UPDATE_MODE.exclude} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Select plugins to exclude')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Component Memoization', () => {
|
|
it('should be memoized with React.memo', () => {
|
|
expect(NoPluginSelected).toBeDefined()
|
|
expect((NoPluginSelected as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// PluginsSelected Component Tests
|
|
// ============================================================
|
|
describe('PluginsSelected (plugins-selected.tsx)', () => {
|
|
describe('Rendering', () => {
|
|
it('should render empty when no plugins', () => {
|
|
// Act
|
|
const { container } = render(<PluginsSelected plugins={[]} />)
|
|
|
|
// Assert
|
|
expect(container.querySelectorAll('[data-testid="plugin-icon"]')).toHaveLength(0)
|
|
})
|
|
|
|
it('should render all plugins when count is below MAX_DISPLAY_COUNT (14)', () => {
|
|
// Arrange
|
|
const plugins = Array.from({ length: 10 }, (_, i) => `plugin-${i}`)
|
|
|
|
// Act
|
|
render(<PluginsSelected plugins={plugins} />)
|
|
|
|
// Assert
|
|
const icons = screen.getAllByTestId('plugin-icon')
|
|
expect(icons).toHaveLength(10)
|
|
})
|
|
|
|
it('should render MAX_DISPLAY_COUNT plugins with overflow indicator when count exceeds limit', () => {
|
|
// Arrange
|
|
const plugins = Array.from({ length: 20 }, (_, i) => `plugin-${i}`)
|
|
|
|
// Act
|
|
render(<PluginsSelected plugins={plugins} />)
|
|
|
|
// Assert
|
|
const icons = screen.getAllByTestId('plugin-icon')
|
|
expect(icons).toHaveLength(14)
|
|
expect(screen.getByText('+6')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render correct icon URLs', () => {
|
|
// Arrange
|
|
const plugins = ['plugin-a', 'plugin-b']
|
|
|
|
// Act
|
|
render(<PluginsSelected plugins={plugins} />)
|
|
|
|
// Assert
|
|
const icons = screen.getAllByTestId('plugin-icon')
|
|
expect(icons[0]).toHaveAttribute('src', expect.stringContaining('plugin-a'))
|
|
expect(icons[1]).toHaveAttribute('src', expect.stringContaining('plugin-b'))
|
|
})
|
|
|
|
it('should apply custom className', () => {
|
|
// Act
|
|
const { container } = render(<PluginsSelected plugins={['test']} className="custom-class" />)
|
|
|
|
// Assert
|
|
expect(container.firstChild).toHaveClass('custom-class')
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle exactly MAX_DISPLAY_COUNT plugins without overflow', () => {
|
|
// Arrange - exactly 14 plugins (MAX_DISPLAY_COUNT)
|
|
const plugins = Array.from({ length: 14 }, (_, i) => `plugin-${i}`)
|
|
|
|
// Act
|
|
render(<PluginsSelected plugins={plugins} />)
|
|
|
|
// Assert - all 14 icons are displayed
|
|
expect(screen.getAllByTestId('plugin-icon')).toHaveLength(14)
|
|
// Note: Component shows "+0" when exactly at limit due to < vs <= comparison
|
|
// This is the actual behavior (isShowAll = plugins.length < MAX_DISPLAY_COUNT)
|
|
})
|
|
|
|
it('should handle MAX_DISPLAY_COUNT + 1 plugins showing overflow', () => {
|
|
// Arrange - 15 plugins
|
|
const plugins = Array.from({ length: 15 }, (_, i) => `plugin-${i}`)
|
|
|
|
// Act
|
|
render(<PluginsSelected plugins={plugins} />)
|
|
|
|
// Assert
|
|
expect(screen.getAllByTestId('plugin-icon')).toHaveLength(14)
|
|
expect(screen.getByText('+1')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Component Memoization', () => {
|
|
it('should be memoized with React.memo', () => {
|
|
expect(PluginsSelected).toBeDefined()
|
|
expect((PluginsSelected as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// ToolItem Component Tests
|
|
// ============================================================
|
|
describe('ToolItem (tool-item.tsx)', () => {
|
|
const defaultProps = {
|
|
payload: createMockPluginDetail(),
|
|
isChecked: false,
|
|
onCheckChange: vi.fn(),
|
|
}
|
|
|
|
describe('Rendering', () => {
|
|
it('should render plugin icon', () => {
|
|
// Act
|
|
render(<ToolItem {...defaultProps} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('plugin-icon')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render plugin label', () => {
|
|
// Arrange
|
|
const props = {
|
|
...defaultProps,
|
|
payload: createMockPluginDetail({
|
|
declaration: createMockPluginDeclaration({
|
|
label: { 'en-US': 'My Test Plugin' } as PluginDeclaration['label'],
|
|
}),
|
|
}),
|
|
}
|
|
|
|
// Act
|
|
render(<ToolItem {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('My Test Plugin')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render plugin author', () => {
|
|
// Arrange
|
|
const props = {
|
|
...defaultProps,
|
|
payload: createMockPluginDetail({
|
|
declaration: createMockPluginDeclaration({
|
|
author: 'Plugin Author',
|
|
}),
|
|
}),
|
|
}
|
|
|
|
// Act
|
|
render(<ToolItem {...props} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Plugin Author')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render checkbox unchecked when isChecked is false', () => {
|
|
// Act
|
|
render(<ToolItem {...defaultProps} isChecked={false} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('checkbox')).not.toBeChecked()
|
|
})
|
|
|
|
it('should render checkbox checked when isChecked is true', () => {
|
|
// Act
|
|
render(<ToolItem {...defaultProps} isChecked={true} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('checkbox')).toBeChecked()
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should call onCheckChange when checkbox is clicked', () => {
|
|
// Arrange
|
|
const onCheckChange = vi.fn()
|
|
|
|
// Act
|
|
render(<ToolItem {...defaultProps} onCheckChange={onCheckChange} />)
|
|
fireEvent.click(screen.getByTestId('checkbox'))
|
|
|
|
// Assert
|
|
expect(onCheckChange).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('Component Memoization', () => {
|
|
it('should be memoized with React.memo', () => {
|
|
expect(ToolItem).toBeDefined()
|
|
expect((ToolItem as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// StrategyPicker Component Tests
|
|
// ============================================================
|
|
describe('StrategyPicker (strategy-picker.tsx)', () => {
|
|
const defaultProps = {
|
|
value: AUTO_UPDATE_STRATEGY.disabled,
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
describe('Rendering', () => {
|
|
it('should render trigger button with current strategy label', () => {
|
|
// Act
|
|
render(<StrategyPicker {...defaultProps} value={AUTO_UPDATE_STRATEGY.disabled} />)
|
|
|
|
// Assert
|
|
expect(screen.getByRole('button', { name: /disabled/i })).toBeInTheDocument()
|
|
})
|
|
|
|
it('should not render dropdown content when closed', () => {
|
|
// Act
|
|
render(<StrategyPicker {...defaultProps} />)
|
|
|
|
// Assert
|
|
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should render all strategy options when open', () => {
|
|
// Arrange
|
|
mockPortalOpen = true
|
|
|
|
// Act
|
|
render(<StrategyPicker {...defaultProps} />)
|
|
fireEvent.click(screen.getByTestId('portal-trigger'))
|
|
|
|
// Wait for portal to open
|
|
if (mockPortalOpen) {
|
|
// Assert all options visible (use getAllByText for "Disabled" as it appears in both trigger and dropdown)
|
|
expect(screen.getAllByText('Disabled').length).toBeGreaterThanOrEqual(1)
|
|
expect(screen.getByText('Bug Fixes Only')).toBeInTheDocument()
|
|
expect(screen.getByText('Latest Version')).toBeInTheDocument()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should toggle dropdown when trigger is clicked', () => {
|
|
// Act
|
|
render(<StrategyPicker {...defaultProps} />)
|
|
|
|
// Assert - initially closed
|
|
expect(mockPortalOpen).toBe(false)
|
|
|
|
// Act - click trigger
|
|
fireEvent.click(screen.getByTestId('portal-trigger'))
|
|
|
|
// Assert - portal trigger element should still be in document
|
|
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should call onChange with fixOnly when Bug Fixes Only option is clicked', () => {
|
|
// Arrange - force portal content to be visible for testing option selection
|
|
forcePortalContentVisible = true
|
|
const onChange = vi.fn()
|
|
|
|
// Act
|
|
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
|
|
|
|
// Find and click the "Bug Fixes Only" option
|
|
const fixOnlyOption = screen.getByText('Bug Fixes Only').closest('div[class*="cursor-pointer"]')
|
|
expect(fixOnlyOption).toBeInTheDocument()
|
|
fireEvent.click(fixOnlyOption!)
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
|
|
})
|
|
|
|
it('should call onChange with latest when Latest Version option is clicked', () => {
|
|
// Arrange - force portal content to be visible for testing option selection
|
|
forcePortalContentVisible = true
|
|
const onChange = vi.fn()
|
|
|
|
// Act
|
|
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
|
|
|
|
// Find and click the "Latest Version" option
|
|
const latestOption = screen.getByText('Latest Version').closest('div[class*="cursor-pointer"]')
|
|
expect(latestOption).toBeInTheDocument()
|
|
fireEvent.click(latestOption!)
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
|
|
})
|
|
|
|
it('should call onChange with disabled when Disabled option is clicked', () => {
|
|
// Arrange - force portal content to be visible for testing option selection
|
|
forcePortalContentVisible = true
|
|
const onChange = vi.fn()
|
|
|
|
// Act
|
|
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={onChange} />)
|
|
|
|
// Find and click the "Disabled" option - need to find the one in the dropdown, not the button
|
|
const disabledOptions = screen.getAllByText('Disabled')
|
|
// The second one should be in the dropdown
|
|
const dropdownOption = disabledOptions.find(el => el.closest('div[class*="cursor-pointer"]'))
|
|
expect(dropdownOption).toBeInTheDocument()
|
|
fireEvent.click(dropdownOption!.closest('div[class*="cursor-pointer"]')!)
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.disabled)
|
|
})
|
|
|
|
it('should stop event propagation when option is clicked', () => {
|
|
// Arrange - force portal content to be visible
|
|
forcePortalContentVisible = true
|
|
const onChange = vi.fn()
|
|
const parentClickHandler = vi.fn()
|
|
|
|
// Act
|
|
render(
|
|
<div onClick={parentClickHandler}>
|
|
<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />
|
|
</div>,
|
|
)
|
|
|
|
// Click an option
|
|
const fixOnlyOption = screen.getByText('Bug Fixes Only').closest('div[class*="cursor-pointer"]')
|
|
fireEvent.click(fixOnlyOption!)
|
|
|
|
// Assert - onChange is called but parent click handler should not propagate
|
|
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
|
|
})
|
|
|
|
it('should render check icon for currently selected option', () => {
|
|
// Arrange - force portal content to be visible
|
|
forcePortalContentVisible = true
|
|
|
|
// Act - render with fixOnly selected
|
|
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
|
|
|
|
// Assert - RiCheckLine should be rendered (check icon)
|
|
// Find all "Bug Fixes Only" texts and get the one in the dropdown (has cursor-pointer parent)
|
|
const allFixOnlyTexts = screen.getAllByText('Bug Fixes Only')
|
|
const dropdownOption = allFixOnlyTexts.find(el => el.closest('div[class*="cursor-pointer"]'))
|
|
const optionContainer = dropdownOption?.closest('div[class*="cursor-pointer"]')
|
|
expect(optionContainer).toBeInTheDocument()
|
|
// The check icon SVG should exist within the option
|
|
expect(optionContainer?.querySelector('svg')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should not render check icon for non-selected options', () => {
|
|
// Arrange - force portal content to be visible
|
|
forcePortalContentVisible = true
|
|
|
|
// Act - render with disabled selected
|
|
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
|
|
|
// Assert - check the Latest Version option should not have check icon
|
|
const latestOption = screen.getByText('Latest Version').closest('div[class*="cursor-pointer"]')
|
|
// The svg should only be in selected option, not in non-selected
|
|
const checkIconContainer = latestOption?.querySelector('div.mr-1')
|
|
// Non-selected option should have empty check icon container
|
|
expect(checkIconContainer?.querySelector('svg')).toBeNull()
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// ToolPicker Component Tests
|
|
// ============================================================
|
|
describe('ToolPicker (tool-picker.tsx)', () => {
|
|
const defaultProps = {
|
|
trigger: <button>Select Plugins</button>,
|
|
value: [] as string[],
|
|
onChange: vi.fn(),
|
|
isShow: false,
|
|
onShowChange: vi.fn(),
|
|
}
|
|
|
|
describe('Rendering', () => {
|
|
it('should render trigger element', () => {
|
|
// Act
|
|
render(<ToolPicker {...defaultProps} />)
|
|
|
|
// Assert
|
|
expect(screen.getByRole('button', { name: 'Select Plugins' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('should not render content when isShow is false', () => {
|
|
// Act
|
|
render(<ToolPicker {...defaultProps} isShow={false} />)
|
|
|
|
// Assert
|
|
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should render search box and tabs when isShow is true', () => {
|
|
// Arrange
|
|
mockPortalOpen = true
|
|
|
|
// Act
|
|
render(<ToolPicker {...defaultProps} isShow={true} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('search-box')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show NoDataPlaceholder when no plugins and no search query', () => {
|
|
// Arrange
|
|
mockPortalOpen = true
|
|
mockPluginsData.plugins = []
|
|
|
|
// Act
|
|
renderWithQueryClient(<ToolPicker {...defaultProps} isShow={true} />)
|
|
|
|
// Assert - should show "No plugins installed" when no query
|
|
expect(screen.getByTestId('group-icon')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Filtering', () => {
|
|
beforeEach(() => {
|
|
mockPluginsData.plugins = [
|
|
createMockPluginDetail({
|
|
plugin_id: 'tool-plugin',
|
|
source: PluginSource.marketplace,
|
|
declaration: createMockPluginDeclaration({
|
|
category: PluginCategoryEnum.tool,
|
|
label: { 'en-US': 'Tool Plugin' } as PluginDeclaration['label'],
|
|
}),
|
|
}),
|
|
createMockPluginDetail({
|
|
plugin_id: 'model-plugin',
|
|
source: PluginSource.marketplace,
|
|
declaration: createMockPluginDeclaration({
|
|
category: PluginCategoryEnum.model,
|
|
label: { 'en-US': 'Model Plugin' } as PluginDeclaration['label'],
|
|
}),
|
|
}),
|
|
createMockPluginDetail({
|
|
plugin_id: 'github-plugin',
|
|
source: PluginSource.github,
|
|
declaration: createMockPluginDeclaration({
|
|
label: { 'en-US': 'GitHub Plugin' } as PluginDeclaration['label'],
|
|
}),
|
|
}),
|
|
]
|
|
})
|
|
|
|
it('should filter out non-marketplace plugins', () => {
|
|
// Arrange
|
|
mockPortalOpen = true
|
|
|
|
// Act
|
|
renderWithQueryClient(<ToolPicker {...defaultProps} isShow={true} />)
|
|
|
|
// Assert - GitHub plugin should not be shown
|
|
expect(screen.queryByText('GitHub Plugin')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should filter by search query', () => {
|
|
// Arrange
|
|
mockPortalOpen = true
|
|
|
|
// Act
|
|
renderWithQueryClient(<ToolPicker {...defaultProps} isShow={true} />)
|
|
|
|
// Type in search box
|
|
fireEvent.change(screen.getByTestId('search-input'), { target: { value: 'tool' } })
|
|
|
|
// Assert - only tool plugin should match
|
|
expect(screen.getByText('Tool Plugin')).toBeInTheDocument()
|
|
expect(screen.queryByText('Model Plugin')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should call onShowChange when trigger is clicked', () => {
|
|
// Arrange
|
|
const onShowChange = vi.fn()
|
|
|
|
// Act
|
|
render(<ToolPicker {...defaultProps} onShowChange={onShowChange} />)
|
|
fireEvent.click(screen.getByTestId('portal-trigger'))
|
|
|
|
// Assert
|
|
expect(onShowChange).toHaveBeenCalledWith(true)
|
|
})
|
|
|
|
it('should call onChange when plugin is selected', () => {
|
|
// Arrange
|
|
mockPortalOpen = true
|
|
mockPluginsData.plugins = [
|
|
createMockPluginDetail({
|
|
plugin_id: 'test-plugin',
|
|
source: PluginSource.marketplace,
|
|
declaration: createMockPluginDeclaration({ label: { 'en-US': 'Test Plugin' } as PluginDeclaration['label'] }),
|
|
}),
|
|
]
|
|
const onChange = vi.fn()
|
|
|
|
// Act
|
|
renderWithQueryClient(<ToolPicker {...defaultProps} isShow={true} onChange={onChange} />)
|
|
fireEvent.click(screen.getByTestId('checkbox'))
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalledWith(['test-plugin'])
|
|
})
|
|
|
|
it('should unselect plugin when already selected', () => {
|
|
// Arrange
|
|
mockPortalOpen = true
|
|
mockPluginsData.plugins = [
|
|
createMockPluginDetail({
|
|
plugin_id: 'test-plugin',
|
|
source: PluginSource.marketplace,
|
|
}),
|
|
]
|
|
const onChange = vi.fn()
|
|
|
|
// Act
|
|
renderWithQueryClient(
|
|
<ToolPicker {...defaultProps} isShow={true} value={['test-plugin']} onChange={onChange} />,
|
|
)
|
|
fireEvent.click(screen.getByTestId('checkbox'))
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalledWith([])
|
|
})
|
|
})
|
|
|
|
describe('Callback Memoization', () => {
|
|
it('handleCheckChange should be memoized with correct dependencies', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
mockPortalOpen = true
|
|
mockPluginsData.plugins = [
|
|
createMockPluginDetail({
|
|
plugin_id: 'plugin-1',
|
|
source: PluginSource.marketplace,
|
|
}),
|
|
]
|
|
|
|
// Act - render and interact
|
|
const { rerender } = renderWithQueryClient(
|
|
<ToolPicker {...defaultProps} isShow={true} value={[]} onChange={onChange} />,
|
|
)
|
|
|
|
// Click to select
|
|
fireEvent.click(screen.getByTestId('checkbox'))
|
|
expect(onChange).toHaveBeenCalledWith(['plugin-1'])
|
|
|
|
// Rerender with new value
|
|
onChange.mockClear()
|
|
rerender(
|
|
<QueryClientProvider client={createQueryClient()}>
|
|
<ToolPicker {...defaultProps} isShow={true} value={['plugin-1']} onChange={onChange} />
|
|
</QueryClientProvider>,
|
|
)
|
|
|
|
// Click to unselect
|
|
fireEvent.click(screen.getByTestId('checkbox'))
|
|
expect(onChange).toHaveBeenCalledWith([])
|
|
})
|
|
})
|
|
|
|
describe('Component Memoization', () => {
|
|
it('should be memoized with React.memo', () => {
|
|
expect(ToolPicker).toBeDefined()
|
|
expect((ToolPicker as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// PluginsPicker Component Tests
|
|
// ============================================================
|
|
describe('PluginsPicker (plugins-picker.tsx)', () => {
|
|
const defaultProps = {
|
|
updateMode: AUTO_UPDATE_MODE.partial,
|
|
value: [] as string[],
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
describe('Rendering', () => {
|
|
it('should render NoPluginSelected when no plugins selected', () => {
|
|
// Act
|
|
render(<PluginsPicker {...defaultProps} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Select plugins to update')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render selected plugins count and clear button when plugins selected', () => {
|
|
// Act
|
|
render(<PluginsPicker {...defaultProps} value={['plugin-1', 'plugin-2']} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText(/Updating 2 plugins/i)).toBeInTheDocument()
|
|
expect(screen.getByText('Clear All')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render select button', () => {
|
|
// Act
|
|
render(<PluginsPicker {...defaultProps} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Select Plugins')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show exclude mode text when in exclude mode', () => {
|
|
// Act
|
|
render(
|
|
<PluginsPicker
|
|
{...defaultProps}
|
|
updateMode={AUTO_UPDATE_MODE.exclude}
|
|
value={['plugin-1']}
|
|
/>,
|
|
)
|
|
|
|
// Assert
|
|
expect(screen.getByText(/Excluding 1 plugins/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should call onChange with empty array when clear is clicked', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
|
|
// Act
|
|
render(
|
|
<PluginsPicker
|
|
{...defaultProps}
|
|
value={['plugin-1', 'plugin-2']}
|
|
onChange={onChange}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByText('Clear All'))
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalledWith([])
|
|
})
|
|
})
|
|
|
|
describe('Component Memoization', () => {
|
|
it('should be memoized with React.memo', () => {
|
|
expect(PluginsPicker).toBeDefined()
|
|
expect((PluginsPicker as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// AutoUpdateSetting Main Component Tests
|
|
// ============================================================
|
|
describe('AutoUpdateSetting (index.tsx)', () => {
|
|
const defaultProps = {
|
|
payload: createMockAutoUpdateConfig(),
|
|
onChange: vi.fn(),
|
|
}
|
|
|
|
describe('Rendering', () => {
|
|
it('should render update settings header', () => {
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Update Settings')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render automatic updates label', () => {
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Automatic Updates')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render strategy picker', () => {
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} />)
|
|
|
|
// Assert
|
|
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show time picker when strategy is not disabled', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Update Time')).toBeInTheDocument()
|
|
expect(screen.getByTestId('time-picker')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should hide time picker and plugins selection when strategy is disabled', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.disabled })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert
|
|
expect(screen.queryByText('Update Time')).not.toBeInTheDocument()
|
|
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should show plugins picker when mode is not update_all', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.partial,
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Select Plugins')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should hide plugins picker when mode is update_all', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.update_all,
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert
|
|
expect(screen.queryByText('Select Plugins')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Strategy Description', () => {
|
|
it('should show fixOnly description when strategy is fixOnly', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Only apply bug fixes')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show latest description when strategy is latest', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.latest })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Always update to latest')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show no description when strategy is disabled', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.disabled })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert
|
|
expect(screen.queryByText('Only apply bug fixes')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Always update to latest')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Plugins Selection', () => {
|
|
it('should show include_plugins when mode is partial', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.partial,
|
|
include_plugins: ['plugin-1', 'plugin-2'],
|
|
exclude_plugins: [],
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText(/Updating 2 plugins/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show exclude_plugins when mode is exclude', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.exclude,
|
|
include_plugins: [],
|
|
exclude_plugins: ['plugin-1', 'plugin-2', 'plugin-3'],
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText(/Excluding 3 plugins/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should call onChange with updated strategy when strategy changes', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
const payload = createMockAutoUpdateConfig()
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
|
|
|
// Assert - component renders with strategy picker
|
|
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should call onChange with updated time when time changes', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
|
|
|
// Click time picker trigger
|
|
fireEvent.click(screen.getByTestId('time-picker').querySelector('[data-testid="time-input"]')!.parentElement!)
|
|
|
|
// Set time
|
|
fireEvent.click(screen.getByTestId('time-picker-set'))
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should call onChange with 0 when time is cleared', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
|
|
|
// Click time picker trigger
|
|
fireEvent.click(screen.getByTestId('time-picker').querySelector('[data-testid="time-input"]')!.parentElement!)
|
|
|
|
// Clear time
|
|
fireEvent.click(screen.getByTestId('time-picker-clear'))
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should call onChange with include_plugins when in partial mode', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.partial,
|
|
include_plugins: ['existing-plugin'],
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
|
|
|
// Click clear all
|
|
fireEvent.click(screen.getByText('Clear All'))
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
|
include_plugins: [],
|
|
}))
|
|
})
|
|
|
|
it('should call onChange with exclude_plugins when in exclude mode', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.exclude,
|
|
exclude_plugins: ['existing-plugin'],
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
|
|
|
// Click clear all
|
|
fireEvent.click(screen.getByText('Clear All'))
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
|
exclude_plugins: [],
|
|
}))
|
|
})
|
|
|
|
it('should open account settings when timezone link is clicked', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert - timezone text is rendered
|
|
expect(screen.getByText(/Change in/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Callback Memoization', () => {
|
|
it('minuteFilter should filter to 15 minute intervals', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// The minuteFilter is passed to TimePicker internally
|
|
// We verify the component renders correctly
|
|
expect(screen.getByTestId('time-picker')).toBeInTheDocument()
|
|
})
|
|
|
|
it('handleChange should preserve other config values', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_time_of_day: 36000,
|
|
upgrade_mode: AUTO_UPDATE_MODE.partial,
|
|
include_plugins: ['plugin-1'],
|
|
exclude_plugins: [],
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
|
|
|
// Trigger a change (clear plugins)
|
|
fireEvent.click(screen.getByText('Clear All'))
|
|
|
|
// Assert - other values should be preserved
|
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_time_of_day: 36000,
|
|
upgrade_mode: AUTO_UPDATE_MODE.partial,
|
|
}))
|
|
})
|
|
|
|
it('handlePluginsChange should not update when mode is update_all', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.update_all,
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
|
|
|
// Plugin picker should not be visible in update_all mode
|
|
expect(screen.queryByText('Clear All')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Memoization Logic', () => {
|
|
it('strategyDescription should update when strategy_setting changes', () => {
|
|
// Arrange
|
|
const payload1 = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
|
const { rerender } = render(<AutoUpdateSetting {...defaultProps} payload={payload1} />)
|
|
|
|
// Assert initial
|
|
expect(screen.getByText('Only apply bug fixes')).toBeInTheDocument()
|
|
|
|
// Act - change strategy
|
|
const payload2 = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.latest })
|
|
rerender(<AutoUpdateSetting {...defaultProps} payload={payload2} />)
|
|
|
|
// Assert updated
|
|
expect(screen.getByText('Always update to latest')).toBeInTheDocument()
|
|
})
|
|
|
|
it('plugins should reflect correct list based on upgrade_mode', () => {
|
|
// Arrange
|
|
const partialPayload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.partial,
|
|
include_plugins: ['include-1', 'include-2'],
|
|
exclude_plugins: ['exclude-1'],
|
|
})
|
|
const { rerender } = render(<AutoUpdateSetting {...defaultProps} payload={partialPayload} />)
|
|
|
|
// Assert - partial mode shows include_plugins count
|
|
expect(screen.getByText(/Updating 2 plugins/i)).toBeInTheDocument()
|
|
|
|
// Act - change to exclude mode
|
|
const excludePayload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.exclude,
|
|
include_plugins: ['include-1', 'include-2'],
|
|
exclude_plugins: ['exclude-1'],
|
|
})
|
|
rerender(<AutoUpdateSetting {...defaultProps} payload={excludePayload} />)
|
|
|
|
// Assert - exclude mode shows exclude_plugins count
|
|
expect(screen.getByText(/Excluding 1 plugins/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Component Memoization', () => {
|
|
it('should be memoized with React.memo', () => {
|
|
expect(AutoUpdateSetting).toBeDefined()
|
|
expect((AutoUpdateSetting as any).$$typeof?.toString()).toContain('Symbol')
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle empty payload values gracefully', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
include_plugins: [],
|
|
exclude_plugins: [],
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('Update Settings')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should handle null timezone gracefully', () => {
|
|
// This tests the timezone! non-null assertion in the component
|
|
// The mock provides a valid timezone, so the component should work
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert - should render without errors
|
|
expect(screen.getByTestId('time-picker')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render timezone offset correctly', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert - should show timezone offset
|
|
expect(screen.getByText('GMT-5')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Upgrade Mode Options', () => {
|
|
it('should render all three upgrade mode options', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert
|
|
expect(screen.getByText('All Plugins')).toBeInTheDocument()
|
|
expect(screen.getByText('Exclude Selected')).toBeInTheDocument()
|
|
expect(screen.getByText('Selected Only')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should highlight selected upgrade mode', () => {
|
|
// Arrange
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.partial,
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
|
|
|
// Assert - OptionCard component will be rendered for each mode
|
|
expect(screen.getByText('All Plugins')).toBeInTheDocument()
|
|
expect(screen.getByText('Exclude Selected')).toBeInTheDocument()
|
|
expect(screen.getByText('Selected Only')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should call onChange when upgrade mode is changed', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.update_all,
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
|
|
|
// Click on partial mode - find the option card for partial
|
|
const partialOption = screen.getByText('Selected Only')
|
|
fireEvent.click(partialOption)
|
|
|
|
// Assert
|
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
|
upgrade_mode: AUTO_UPDATE_MODE.partial,
|
|
}))
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Integration Tests
|
|
// ============================================================
|
|
describe('Integration', () => {
|
|
it('should handle full workflow: enable updates, set time, select plugins', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
let currentPayload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.disabled,
|
|
})
|
|
|
|
const { rerender } = render(
|
|
<AutoUpdateSetting payload={currentPayload} onChange={onChange} />,
|
|
)
|
|
|
|
// Assert - initially disabled
|
|
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument()
|
|
|
|
// Simulate enabling updates
|
|
currentPayload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.partial,
|
|
include_plugins: [],
|
|
})
|
|
rerender(<AutoUpdateSetting payload={currentPayload} onChange={onChange} />)
|
|
|
|
// Assert - time picker and plugins visible
|
|
expect(screen.getByTestId('time-picker')).toBeInTheDocument()
|
|
expect(screen.getByText('Select Plugins')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should maintain state consistency when switching modes', () => {
|
|
// Arrange
|
|
const onChange = vi.fn()
|
|
const payload = createMockAutoUpdateConfig({
|
|
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
|
|
upgrade_mode: AUTO_UPDATE_MODE.partial,
|
|
include_plugins: ['plugin-1'],
|
|
exclude_plugins: ['plugin-2'],
|
|
})
|
|
|
|
// Act
|
|
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
|
|
|
// Assert - partial mode shows include_plugins
|
|
expect(screen.getByText(/Updating 1 plugins/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|