import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' vi.mock('./ProjectFilter', () => ({ ProjectFilter: () =>
, })) vi.mock('../../i18n', () => ({ useTranslation: () => (key: string, params?: Record) => { const translations: Record = { 'sidebar.newSession': 'New Session', 'sidebar.scheduled': 'Scheduled', 'sidebar.settings': 'Settings', 'sidebar.searchPlaceholder': 'Search sessions', 'sidebar.noSessions': 'No sessions', 'sidebar.noMatching': 'No matching sessions', 'sidebar.sessionListFailed': 'Session list failed', 'sidebar.refreshSessions': 'Refresh sessions', 'common.retry': 'Retry', 'common.loading': 'Loading...', 'common.cancel': 'Cancel', 'common.delete': 'Delete', 'common.rename': 'Rename', 'sidebar.timeGroup.today': 'Today', 'sidebar.timeGroup.yesterday': 'Yesterday', 'sidebar.timeGroup.last7days': 'Last 7 Days', 'sidebar.timeGroup.last30days': 'Last 30 Days', 'sidebar.timeGroup.older': 'Older', 'sidebar.missingDir': 'Missing', 'sidebar.confirmDelete': 'Delete this session? This cannot be undone.', 'sidebar.batchManage': 'Batch manage', 'sidebar.batchSelectedCount': '{count} selected', 'sidebar.batchSelectAll': 'Select all', 'sidebar.batchDeselectAll': 'Deselect all', 'sidebar.batchSelectGroup': 'Select {group}', 'sidebar.batchDeleteSelected': 'Delete selected ({count})', 'sidebar.batchDeleteConfirm': 'Delete {count} sessions? This cannot be undone.', 'sidebar.batchDeleteConfirmBody': 'The following sessions will be deleted:', 'sidebar.batchDeleteMore': '...and {count} more', 'sidebar.batchExit': 'Cancel batch mode', 'sidebar.batchDeleteSucceeded': 'Deleted {count} sessions.', 'sidebar.batchDeleteFailed': '{count} sessions could not be deleted.', 'sidebar.collapse': 'Collapse sidebar', 'sidebar.expand': 'Expand sidebar', } let text = translations[key] ?? key for (const [name, value] of Object.entries(params ?? {})) { text = text.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value)) } return text }, })) import { Sidebar } from './Sidebar' import { useChatStore } from '../../stores/chatStore' import { useSessionStore } from '../../stores/sessionStore' import { useTabStore } from '../../stores/tabStore' import { useUIStore } from '../../stores/uiStore' describe('Sidebar', () => { const connectToSession = vi.fn() const disconnectSession = vi.fn() const fetchSessions = vi.fn() const createSession = vi.fn() const deleteSession = vi.fn() const deleteSessions = vi.fn() const addToast = vi.fn() beforeEach(() => { connectToSession.mockReset() disconnectSession.mockReset() fetchSessions.mockReset() createSession.mockReset() deleteSession.mockReset() deleteSessions.mockReset() addToast.mockReset() useTabStore.setState({ tabs: [], activeTabId: null }) useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null, selectedProjects: [], availableProjects: [], isBatchMode: false, selectedSessionIds: new Set(), fetchSessions, createSession, deleteSession, deleteSessions, }) useChatStore.setState({ connectToSession, disconnectSession, } as Partial>) useUIStore.setState({ sidebarOpen: true, addToast, } as Partial>) }) afterEach(() => { vi.useRealTimers() cleanup() useTabStore.setState({ tabs: [], activeTabId: null }) }) it('opens a new tab when creating a session from the sidebar', async () => { createSession.mockResolvedValue('session-new-1') render() await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'New Session' })) }) await waitFor(() => { expect(createSession).toHaveBeenCalled() expect(connectToSession).toHaveBeenCalledWith('session-new-1') }) expect(useTabStore.getState().tabs).toEqual([ { sessionId: 'session-new-1', title: 'New Session', type: 'session', status: 'idle' }, ]) expect(useTabStore.getState().activeTabId).toBe('session-new-1') expect(screen.getByRole('complementary')).not.toHaveAttribute('data-tauri-drag-region') }) it('shows a toast when session creation fails', async () => { createSession.mockRejectedValue(new Error('boom')) render() await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'New Session' })) }) await waitFor(() => { expect(addToast).toHaveBeenCalledWith({ type: 'error', message: 'boom', }) }) expect(useTabStore.getState().tabs).toEqual([]) }) it('requires confirmation before deleting a session from the sidebar', async () => { deleteSession.mockResolvedValue(undefined) useSessionStore.setState({ sessions: [ { id: 'session-1', title: 'Open Session', createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), messageCount: 1, projectPath: '/workspace/project', workDir: '/workspace/project', workDirExists: true, }, ], }) useTabStore.setState({ tabs: [{ sessionId: 'session-1', title: 'Open Session', type: 'session', status: 'idle' }], activeTabId: 'session-1', }) render() fireEvent.contextMenu(screen.getByRole('button', { name: /Open Session/ })) fireEvent.click(screen.getByRole('button', { name: 'Delete' })) expect(deleteSession).not.toHaveBeenCalled() const dialog = screen.getByRole('dialog') expect(dialog).toBeInTheDocument() expect(screen.getByText('Delete this session? This cannot be undone.')).toBeInTheDocument() await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) }) await waitFor(() => { expect(deleteSession).toHaveBeenCalledWith('session-1') expect(disconnectSession).toHaveBeenCalledWith('session-1') }) expect(useTabStore.getState().tabs).toEqual([]) expect(useTabStore.getState().activeTabId).toBeNull() }) it('selects and deletes multiple sessions from batch mode', async () => { deleteSessions.mockResolvedValue({ ok: true, successes: ['session-1', 'session-2'], failures: [], }) const now = new Date().toISOString() useSessionStore.setState({ sessions: [ { id: 'session-1', title: 'First Session', createdAt: now, modifiedAt: now, messageCount: 1, projectPath: '/workspace/project', workDir: '/workspace/project', workDirExists: true, }, { id: 'session-2', title: 'Second Session', createdAt: now, modifiedAt: now, messageCount: 1, projectPath: '/workspace/project', workDir: '/workspace/project', workDirExists: true, }, ], }) useTabStore.setState({ tabs: [ { sessionId: 'session-1', title: 'First Session', type: 'session', status: 'idle' }, { sessionId: 'session-2', title: 'Second Session', type: 'session', status: 'idle' }, ], activeTabId: 'session-1', }) render() fireEvent.click(screen.getByRole('button', { name: 'Batch manage' })) fireEvent.click(screen.getByRole('button', { name: /First Session/ })) fireEvent.click(screen.getByRole('button', { name: /Second Session/ })) expect(screen.getByText('2 selected')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Delete selected (2)' })) const dialog = screen.getByRole('dialog') expect(within(dialog).getByText('Delete 2 sessions? This cannot be undone.')).toBeInTheDocument() expect(within(dialog).getByText('First Session')).toBeInTheDocument() expect(within(dialog).getByText('Second Session')).toBeInTheDocument() await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) }) await waitFor(() => { expect(deleteSessions).toHaveBeenCalledWith(['session-1', 'session-2']) expect(disconnectSession).toHaveBeenCalledWith('session-1') expect(disconnectSession).toHaveBeenCalledWith('session-2') }) expect(useTabStore.getState().tabs).toEqual([]) expect(addToast).toHaveBeenCalledWith({ type: 'success', message: 'Deleted 2 sessions.', }) }) it('renders batch-selected sessions as separated selected rows', () => { const now = new Date().toISOString() useSessionStore.setState({ sessions: [ { id: 'session-1', title: 'First Session', createdAt: now, modifiedAt: now, messageCount: 1, projectPath: '/workspace/project', workDir: '/workspace/project', workDirExists: true, }, { id: 'session-2', title: 'Second Session', createdAt: now, modifiedAt: now, messageCount: 1, projectPath: '/workspace/project', workDir: '/workspace/project', workDirExists: true, }, { id: 'session-3', title: 'Third Session', createdAt: now, modifiedAt: now, messageCount: 1, projectPath: '/workspace/project', workDir: '/workspace/project', workDirExists: true, }, ], }) useTabStore.setState({ tabs: [{ sessionId: 'session-2', title: 'Second Session', type: 'session', status: 'idle' }], activeTabId: 'session-2', }) render() fireEvent.click(screen.getByRole('button', { name: 'Batch manage' })) fireEvent.click(screen.getByRole('button', { name: /First Session/ })) expect(screen.getByRole('button', { name: /First Session/ }).parentElement).toHaveClass('mb-1.5') expect(screen.getByRole('button', { name: /First Session/ })).toHaveClass('sidebar-session-row--selected') expect(screen.getByRole('button', { name: /Second Session/ })).toHaveClass('sidebar-session-row--active') expect(screen.getByRole('button', { name: /Third Session/ })).toHaveClass('sidebar-session-row--idle') }) it('collapses into an icon rail and expands back', async () => { render() await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) }) expect(useUIStore.getState().sidebarOpen).toBe(false) expect(screen.queryByPlaceholderText('Search sessions')).not.toBeInTheDocument() expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'closed') expect(screen.getByTestId('sidebar-expand-button')).toHaveClass('sidebar-toggle-button--collapsed') await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Expand sidebar' })) }) expect(useUIStore.getState().sidebarOpen).toBe(true) expect(screen.getByPlaceholderText('Search sessions')).toBeInTheDocument() expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'open') }) it('keeps the project filter section overflow visible for dropdown menus', () => { render() expect(screen.getByTestId('sidebar-project-filter-section')).toHaveStyle({ overflow: 'visible' }) expect(screen.getByTestId('sidebar-project-filter-section')).toHaveClass('relative', 'z-20') }) it('keeps the session list section in a constrained flex column for scrolling', () => { render() expect(screen.getByTestId('sidebar-session-list-section')).toHaveClass('flex', 'flex-1', 'min-h-0', 'flex-col') }) it('keeps mobile navigation focused on chat sessions', async () => { const onRequestClose = vi.fn() createSession.mockResolvedValue('session-mobile-new') useSessionStore.setState({ sessions: [ { id: 'session-1', title: 'Open Session', createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), messageCount: 1, projectPath: '/workspace/project', workDir: '/workspace/project', workDirExists: true, }, ], }) render() expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /Open Session/ })) expect(onRequestClose).toHaveBeenCalledTimes(1) await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'New Session' })) }) await waitFor(() => { expect(createSession).toHaveBeenCalled() }) expect(onRequestClose).toHaveBeenCalledTimes(2) }) it('shows a loading state instead of an empty session list while initial fetch is pending', () => { useSessionStore.setState({ isLoading: true, sessions: [] }) render() expect(screen.getByText('Loading...')).toBeInTheDocument() expect(screen.queryByText('No sessions')).not.toBeInTheDocument() }) it('refreshes sessions manually and through low-frequency visible polling', async () => { vi.useFakeTimers() render() await act(async () => { await Promise.resolve() }) expect(fetchSessions).toHaveBeenCalledTimes(1) await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Refresh sessions' })) await Promise.resolve() }) expect(fetchSessions).toHaveBeenCalledTimes(2) await act(async () => { window.dispatchEvent(new Event('focus')) await Promise.resolve() }) expect(fetchSessions).toHaveBeenCalledTimes(2) await act(async () => { vi.advanceTimersByTime(30_000) await Promise.resolve() }) expect(fetchSessions).toHaveBeenCalledTimes(3) }) it('does not poll for session changes while the document is hidden', async () => { vi.useFakeTimers() const originalVisibility = document.visibilityState Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden', }) render() await act(async () => { await Promise.resolve() }) expect(fetchSessions).toHaveBeenCalledTimes(1) await act(async () => { vi.advanceTimersByTime(30_000) await Promise.resolve() }) expect(fetchSessions).toHaveBeenCalledTimes(1) Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible', }) await act(async () => { document.dispatchEvent(new Event('visibilitychange')) await Promise.resolve() }) expect(fetchSessions).toHaveBeenCalledTimes(2) Object.defineProperty(document, 'visibilityState', { configurable: true, value: originalVisibility, }) }) })