import { fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useUIStore } from '../../stores/uiStore' import { useSessionStore } from '../../stores/sessionStore' const mocks = vi.hoisted(() => ({ initializeDesktopServerUrl: vi.fn(), isTauriRuntime: false, isMobile: false, fetchAll: vi.fn(), restoreTabs: vi.fn(), connectToSession: vi.fn(), setActiveTab: vi.fn(), tabState: { activeTabId: null as string | null, tabs: [] as Array<{ sessionId: string; title: string; type: string; status: string }>, }, })) vi.mock('../../lib/desktopRuntime', () => ({ initializeDesktopServerUrl: mocks.initializeDesktopServerUrl, isTauriRuntime: () => mocks.isTauriRuntime, isH5ConnectionRequiredError: (error: unknown) => error instanceof Error && error.name === 'H5ConnectionRequiredError', })) vi.mock('../../stores/settingsStore', () => ({ useSettingsStore: (selector: (state: { fetchAll: typeof mocks.fetchAll }) => unknown) => selector({ fetchAll: mocks.fetchAll }), })) vi.mock('../../hooks/useMobileViewport', () => ({ useMobileViewport: () => mocks.isMobile, })) vi.mock('../../stores/tabStore', () => { const useTabStore = (selector: (state: { tabs: typeof mocks.tabState.tabs activeTabId: string | null setActiveTab: typeof mocks.setActiveTab }) => unknown) => selector({ tabs: mocks.tabState.tabs, activeTabId: mocks.tabState.activeTabId, setActiveTab: mocks.setActiveTab, }) useTabStore.getState = () => ({ restoreTabs: mocks.restoreTabs, activeTabId: mocks.tabState.activeTabId, tabs: mocks.tabState.tabs, openTab: vi.fn(), setActiveTab: mocks.setActiveTab, }) useTabStore.setState = (next: { activeTabId?: string | null }) => { if ('activeTabId' in next) mocks.tabState.activeTabId = next.activeTabId ?? null } return { SETTINGS_TAB_ID: '__settings__', useTabStore, } }) vi.mock('../../stores/chatStore', () => ({ useChatStore: { getState: () => ({ connectToSession: mocks.connectToSession, }), }, })) vi.mock('../../hooks/useKeyboardShortcuts', () => ({ useKeyboardShortcuts: vi.fn(), })) vi.mock('../../i18n', () => ({ useTranslation: () => (key: string) => key, })) vi.mock('./Sidebar', () => ({ Sidebar: () => , })) vi.mock('./ContentRouter', () => ({ ContentRouter: () =>
content loaded
, })) vi.mock('./TabBar', () => ({ TabBar: () => , })) vi.mock('./H5ConnectionView', () => ({ H5ConnectionView: ({ error, onConnected }: { error?: string | null; onConnected: () => void }) => (
h5 connection view
{error}
), })) vi.mock('../shared/Toast', () => ({ ToastContainer: () => null, })) vi.mock('../shared/UpdateChecker', () => ({ UpdateChecker: () =>
updates loaded
, })) import { AppShell } from './AppShell' describe('AppShell boot flow', () => { beforeEach(() => { vi.clearAllMocks() mocks.isTauriRuntime = false mocks.isMobile = false mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456') mocks.fetchAll.mockResolvedValue(undefined) mocks.restoreTabs.mockResolvedValue(undefined) mocks.setActiveTab.mockImplementation((sessionId: string) => { mocks.tabState.activeTabId = sessionId }) mocks.tabState.activeTabId = null mocks.tabState.tabs = [] useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) useUIStore.setState({ sidebarOpen: true }) }) it('renders the desktop chrome after server and settings bootstrap', async () => { render() expect(screen.getByText('app.launching')).toBeInTheDocument() expect(await screen.findByText('sidebar loaded')).toBeInTheDocument() expect(screen.getByText('tabs loaded')).toBeInTheDocument() expect(screen.getByText('content loaded')).toBeInTheDocument() expect(screen.getByText('updates loaded')).toBeInTheDocument() }) it('shows startup diagnostics instead of a blank shell when bootstrap fails', async () => { mocks.fetchAll.mockRejectedValueOnce(new Error('settings file could not be read')) render() expect(await screen.findByText('app.serverFailed')).toBeInTheDocument() expect(screen.getByText('settings file could not be read')).toBeInTheDocument() expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument() }) it('keeps the app usable when persisted tab restore fails', async () => { mocks.restoreTabs.mockRejectedValueOnce(new Error('old tab payload is invalid')) render() expect(await screen.findByText('sidebar loaded')).toBeInTheDocument() await waitFor(() => { expect(mocks.restoreTabs).toHaveBeenCalled() }) expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument() }) it('reconnects the restored active session tab after boot', async () => { mocks.tabState.activeTabId = 'session-1' mocks.tabState.tabs = [ { sessionId: 'session-1', title: 'Existing session', type: 'session', status: 'idle', }, ] render() await screen.findByText('sidebar loaded') await waitFor(() => { expect(mocks.connectToSession).toHaveBeenCalledWith('session-1') }) }) it('shows the H5 connection view in browser mode when startup needs H5 auth', async () => { mocks.initializeDesktopServerUrl.mockRejectedValueOnce( Object.assign(new Error('Enter your H5 token to continue.'), { name: 'H5ConnectionRequiredError', serverUrl: 'https://remote.example.com', }), ) render() expect(await screen.findByText('h5 connection view')).toBeInTheDocument() expect(screen.getByText('Enter your H5 token to continue.')).toBeInTheDocument() expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument() }) it('shows the H5 connection view for unreachable remote browser startup failures', async () => { mocks.initializeDesktopServerUrl.mockRejectedValueOnce( Object.assign(new Error('Unable to reach https://remote.example.com. Check the server URL or network access.'), { name: 'H5ConnectionRequiredError', serverUrl: 'https://remote.example.com', }), ) render() expect(await screen.findByText('h5 connection view')).toBeInTheDocument() expect(screen.getByText('Unable to reach https://remote.example.com. Check the server URL or network access.')).toBeInTheDocument() expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument() }) it('retries bootstrap after a successful H5 connection', async () => { mocks.initializeDesktopServerUrl .mockRejectedValueOnce( Object.assign(new Error('The saved H5 token is no longer valid.'), { name: 'H5ConnectionRequiredError', serverUrl: 'https://remote.example.com', }), ) .mockResolvedValueOnce('https://remote.example.com') render() expect(await screen.findByText('h5 connection view')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'retry h5 bootstrap' })) await screen.findByText('sidebar loaded') expect(mocks.initializeDesktopServerUrl).toHaveBeenCalledTimes(2) expect(mocks.fetchAll).toHaveBeenCalledTimes(1) }) it('keeps the Tauri startup error path unchanged', async () => { mocks.isTauriRuntime = true mocks.initializeDesktopServerUrl.mockRejectedValueOnce( Object.assign(new Error('desktop server startup failed'), { name: 'H5ConnectionRequiredError', serverUrl: 'https://remote.example.com', }), ) render() expect(await screen.findByText('app.serverFailed')).toBeInTheDocument() expect(screen.queryByText('h5 connection view')).not.toBeInTheDocument() }) it('renders a mobile drawer toggle and backdrop in browser H5 mode', async () => { mocks.isMobile = true render() await screen.findByText('content loaded') await waitFor(() => { expect(useUIStore.getState().sidebarOpen).toBe(false) }) expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'closed') expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('aria-hidden', 'true') expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('inert') expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument() expect(screen.getByTestId('mobile-sidebar-toggle')).toBeInTheDocument() expect(screen.queryByTestId('sidebar-backdrop')).not.toBeInTheDocument() fireEvent.click(screen.getByTestId('mobile-sidebar-toggle')) expect(useUIStore.getState().sidebarOpen).toBe(true) expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'open') expect(screen.getByText('sidebar loaded')).toBeInTheDocument() expect(screen.getByTestId('sidebar-backdrop')).toBeInTheDocument() fireEvent.click(screen.getByTestId('sidebar-backdrop')) expect(useUIStore.getState().sidebarOpen).toBe(false) expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'closed') }) it('shares the mobile drawer row with the active session title', async () => { mocks.isMobile = true mocks.tabState.activeTabId = 'session-mobile' mocks.tabState.tabs = [ { sessionId: 'session-mobile', title: 'Fallback tab title', type: 'session', status: 'running' }, ] useSessionStore.setState({ sessions: [{ id: 'session-mobile', title: 'Analyze recent commits', createdAt: '2026-05-10T00:00:00.000Z', modifiedAt: new Date().toISOString(), messageCount: 7, projectPath: '/tmp/project', workDir: '/tmp/project', workDirExists: true, }], activeSessionId: 'session-mobile', isLoading: false, error: null, }) render() await screen.findByText('content loaded') const header = screen.getByTestId('mobile-session-header') expect(header).toHaveTextContent('Analyze recent commits') expect(header).toHaveTextContent('session.active') expect(header).toHaveTextContent('session.messages') expect(screen.getByTestId('mobile-sidebar-toggle')).toHaveClass('h-10', 'w-10') }) it('keeps browser H5 mobile on chat tabs when settings was restored as active', async () => { mocks.isMobile = true mocks.tabState.activeTabId = '__settings__' mocks.tabState.tabs = [ { sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' }, { sessionId: 'session-1', title: 'Existing session', type: 'session', status: 'idle' }, ] render() await screen.findByText('content loaded') expect(screen.queryByText('tabs loaded')).not.toBeInTheDocument() await waitFor(() => { expect(mocks.setActiveTab).toHaveBeenCalledWith('session-1') }) }) })