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() return { ...actual, Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record }) => { if (i18nKey === 'autoUpdate.changeTimezone' && components?.setTimezone) { return ( Change in {components.setTimezone} ) } return {i18nKey} }, useTranslation: () => ({ t: (key: string, options?: { ns?: string, num?: number }) => { const translations: Record = { '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
{children}
}, PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode onClick: (e: React.MouseEvent) => void className?: string }) => (
{children}
), PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode className?: string }) => { // Allow forcing content visibility for testing option selection if (!mockPortalOpen && !forcePortalContentVisible) return null return
{children}
}, })) // 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 = {value.format('HH:mm')} return (
{renderTrigger({ inputElem, onClick: () => {}, isOpen: false, })}
) }, })) // 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 }) => (
onSearchChange(e.target.value)} placeholder={placeholder} />
), })) // Mock Checkbox component vi.mock('@/app/components/base/checkbox', () => ({ default: ({ checked, onCheck, className }: { checked?: boolean onCheck: () => void className?: string }) => ( ), })) // Mock Icon component vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ default: ({ size, src }: { size: string, src: string }) => ( plugin icon ), })) // Mock icons vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({ SearchMenu: ({ className }: { className?: string }) => 🔍, })) vi.mock('@/app/components/base/icons/src/vender/other', () => ({ Group: ({ className }: { className?: string }) => 📦, })) // 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, lang: string) => obj[lang] || obj['en-US'] || '', })) // ================================ // Test Data Factories // ================================ const createMockPluginDeclaration = (overrides: Partial = {}): 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 => ({ 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 => ({ 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( {ui} , ) } // ================================ // 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() // 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() // 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() // Assert expect(screen.getByTestId('search-menu-icon')).toBeInTheDocument() }) it('should apply className prop', () => { // Act const { container } = render() // 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() // Assert expect(screen.getByText('Select plugins to update')).toBeInTheDocument() }) it('should render exclude mode placeholder', () => { // Act render() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // Assert expect(screen.getByText('Plugin Author')).toBeInTheDocument() }) it('should render checkbox unchecked when isChecked is false', () => { // Act render() // Assert expect(screen.getByTestId('checkbox')).not.toBeChecked() }) it('should render checkbox checked when isChecked is true', () => { // Act render() // Assert expect(screen.getByTestId('checkbox')).toBeChecked() }) }) describe('User Interactions', () => { it('should call onCheckChange when checkbox is clicked', () => { // Arrange const onCheckChange = vi.fn() // Act render() 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() // Assert expect(screen.getByRole('button', { name: /disabled/i })).toBeInTheDocument() }) it('should not render dropdown content when closed', () => { // Act render() // Assert expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() }) it('should render all strategy options when open', () => { // Arrange mockPortalOpen = true // Act render() 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() // 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() // 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() // 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() // 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(
, ) // 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() // 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() // 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: , value: [] as string[], onChange: vi.fn(), isShow: false, onShowChange: vi.fn(), } describe('Rendering', () => { it('should render trigger element', () => { // Act render() // Assert expect(screen.getByRole('button', { name: 'Select Plugins' })).toBeInTheDocument() }) it('should not render content when isShow is false', () => { // Act render() // Assert expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() }) it('should render search box and tabs when isShow is true', () => { // Arrange mockPortalOpen = true // Act render() // 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() // 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() // 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() // 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() 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() 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( , ) 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( , ) // Click to select fireEvent.click(screen.getByTestId('checkbox')) expect(onChange).toHaveBeenCalledWith(['plugin-1']) // Rerender with new value onChange.mockClear() rerender( , ) // 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() // Assert expect(screen.getByText('Select plugins to update')).toBeInTheDocument() }) it('should render selected plugins count and clear button when plugins selected', () => { // Act render() // Assert expect(screen.getByText(/Updating 2 plugins/i)).toBeInTheDocument() expect(screen.getByText('Clear All')).toBeInTheDocument() }) it('should render select button', () => { // Act render() // Assert expect(screen.getByText('Select Plugins')).toBeInTheDocument() }) it('should show exclude mode text when in exclude mode', () => { // Act render( , ) // 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( , ) 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() // Assert expect(screen.getByText('Update Settings')).toBeInTheDocument() }) it('should render automatic updates label', () => { // Act render() // Assert expect(screen.getByText('Automatic Updates')).toBeInTheDocument() }) it('should render strategy picker', () => { // Act render() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // Assert initial expect(screen.getByText('Only apply bug fixes')).toBeInTheDocument() // Act - change strategy const payload2 = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.latest }) rerender() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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() // 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( , ) // 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() // 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() // Assert - partial mode shows include_plugins expect(screen.getByText(/Updating 1 plugins/i)).toBeInTheDocument() }) }) })