codev / desktop /src /components /layout /AppShell.test.tsx
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame
11.2 kB
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: () => <aside>sidebar loaded</aside>,
}))
vi.mock('./ContentRouter', () => ({
ContentRouter: () => <section>content loaded</section>,
}))
vi.mock('./TabBar', () => ({
TabBar: () => <nav>tabs loaded</nav>,
}))
vi.mock('./H5ConnectionView', () => ({
H5ConnectionView: ({ error, onConnected }: { error?: string | null; onConnected: () => void }) => (
<div>
<div>h5 connection view</div>
<div>{error}</div>
<button type="button" onClick={onConnected}>retry h5 bootstrap</button>
</div>
),
}))
vi.mock('../shared/Toast', () => ({
ToastContainer: () => null,
}))
vi.mock('../shared/UpdateChecker', () => ({
UpdateChecker: () => <div>updates loaded</div>,
}))
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(<AppShell />)
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(<AppShell />)
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(<AppShell />)
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(<AppShell />)
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(<AppShell />)
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(<AppShell />)
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(<AppShell />)
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(<AppShell />)
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(<AppShell />)
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(<AppShell />)
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(<AppShell />)
await screen.findByText('content loaded')
expect(screen.queryByText('tabs loaded')).not.toBeInTheDocument()
await waitFor(() => {
expect(mocks.setActiveTab).toHaveBeenCalledWith('session-1')
})
})
})