import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from '../../src/App'; import i18n, { i18nReady } from '../../src/i18n'; import { UPDATE_DISMISSED_VERSION_KEY, UPDATE_INFO_REFETCH_MS, } from '../../src/config/ui-constants'; import type { ConnectResult, ServerInfo } from '../../src/services/backend-client'; const appStateConfig = vi.hoisted(() => ({ initialViewMode: 'onboarding' as 'onboarding' | 'loading' | 'exploring', })); const backendMocks = vi.hoisted(() => ({ fetchServerInfo: vi.fn<() => Promise>(), fetchRepos: vi.fn(async () => []), connectHeartbeat: vi.fn(), })); vi.mock('../../src/hooks/useAppState', async () => { const React = await import('react'); const AppStateContext = React.createContext | null>(null); return { AppStateProvider: ({ children }: { children: React.ReactNode }) => { const [viewMode, setViewMode] = React.useState(appStateConfig.initialViewMode); const [serverBaseUrl, setServerBaseUrl] = React.useState(null); const stable = React.useRef({ setGraph: vi.fn(), setGraphMode: vi.fn(), setChatOnlyNodeCount: vi.fn(), setProgress: vi.fn(), setProjectName: vi.fn(), setSettingsPanelOpen: vi.fn(), refreshLLMSettings: vi.fn(), initializeAgent: vi.fn(async () => {}), startEmbeddingsWithFallback: vi.fn(), setAvailableRepos: vi.fn(), switchRepo: vi.fn(async () => {}), setCurrentRepo: vi.fn(), }).current; return ( {children} ); }, useAppState: () => { const value = React.useContext(AppStateContext); if (!value) throw new Error('Missing test AppStateProvider'); return value; }, }; }); const connectResult: ConnectResult = { nodes: [], relationships: [], repoInfo: { name: 'demo', path: '/workspace/demo', repoPath: '/workspace/demo', indexedAt: '2026-09-04T00:00:00.000Z', }, graphSkipped: false, }; vi.mock('../../src/components/DropZone', () => ({ DropZone: ({ onServerConnect, }: { onServerConnect: (result: ConnectResult, serverUrl: string) => Promise; }) => ( ), })); vi.mock('../../src/components/LoadingOverlay', () => ({ LoadingOverlay: () =>
Loading view
, })); vi.mock('../../src/components/Header', () => ({ Header: () =>
Header
})); vi.mock('../../src/components/GraphCanvas', async () => { const React = await import('react'); return { GraphCanvas: React.forwardRef(() =>
Graph
) }; }); vi.mock('../../src/components/RightPanel', () => ({ RightPanel: () => null })); vi.mock('../../src/components/SettingsPanel', () => ({ SettingsPanel: () => null })); vi.mock('../../src/components/StatusBar', () => ({ StatusBar: () => null })); vi.mock('../../src/components/FileTreePanel', () => ({ FileTreePanel: () => null })); vi.mock('../../src/components/CodeReferencesPanel', () => ({ CodeReferencesPanel: () => null, })); vi.mock('../../src/core/llm/settings-service', () => ({ getActiveProviderConfig: () => null, })); vi.mock('../../src/services/backend-client', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchServerInfo: backendMocks.fetchServerInfo, fetchRepos: backendMocks.fetchRepos, connectHeartbeat: backendMocks.connectHeartbeat, }; }); const updateInfo = (latestVersion = '2.0.0'): ServerInfo => ({ version: '1.0.0', launchContext: 'global', nodeVersion: 'v22.0.0', latestVersion, updateAvailable: true, }); async function connectBackend() { await userEvent.click(screen.getByRole('button', { name: 'Connect test backend' })); } describe('update banner', () => { beforeEach(async () => { await i18nReady; await i18n.changeLanguage('en'); appStateConfig.initialViewMode = 'onboarding'; localStorage.removeItem(UPDATE_DISMISSED_VERSION_KEY); backendMocks.fetchServerInfo.mockReset(); backendMocks.fetchRepos.mockClear(); backendMocks.connectHeartbeat.mockReset(); backendMocks.connectHeartbeat.mockReturnValue(() => {}); }); afterEach(() => { cleanup(); window.history.replaceState(null, '', '/'); }); it('fetches only after a backend is selected and renders interpolated update copy', async () => { backendMocks.fetchServerInfo.mockResolvedValue(updateInfo()); render(); expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled(); expect(screen.queryByRole('status')).not.toBeInTheDocument(); await connectBackend(); expect(await screen.findByRole('status')).toHaveTextContent( 'GitNexus 2.0.0 is available — this server runs 1.0.0.', ); expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1); }); it.each([ ['false', { ...updateInfo(), updateAvailable: false }], ['absent', { version: '1.0.0', launchContext: 'global', nodeVersion: 'v22.0.0' }], ])('stays hidden when update state is %s', async (_label, info) => { backendMocks.fetchServerInfo.mockResolvedValue(info as ServerInfo); render(); await connectBackend(); await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1)); expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); it('gives the reconnect banner priority and refetches after reconnect', async () => { backendMocks.fetchServerInfo.mockResolvedValue(updateInfo()); render(); await connectBackend(); expect(await screen.findByRole('status')).toBeInTheDocument(); const [onConnect, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0]; act(() => onReconnecting()); expect(screen.queryByRole('status')).not.toBeInTheDocument(); expect(screen.getByText(/reconnect/i)).toBeInTheDocument(); await act(async () => onConnect()); await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2)); expect(screen.getByRole('status')).toBeInTheDocument(); }); it('persists dismissal across remounts', async () => { backendMocks.fetchServerInfo.mockResolvedValue(updateInfo()); const first = render(); await connectBackend(); await userEvent.click( await screen.findByRole('button', { name: 'Dismiss update notification' }), ); expect(localStorage.getItem(UPDATE_DISMISSED_VERSION_KEY)).toBe('2.0.0'); expect(screen.queryByRole('status')).not.toBeInTheDocument(); first.unmount(); window.history.replaceState(null, '', '/'); render(); await connectBackend(); await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2)); expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); it('re-shows after a newer version than the dismissed one appears', async () => { localStorage.setItem(UPDATE_DISMISSED_VERSION_KEY, '2.0.0'); backendMocks.fetchServerInfo.mockResolvedValue(updateInfo('2.1.0')); render(); await connectBackend(); expect(await screen.findByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available'); }); it('fails open without rendering an error UI', async () => { backendMocks.fetchServerInfo.mockRejectedValue(new Error('offline')); render(); await connectBackend(); await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1)); expect(screen.queryByRole('status')).not.toBeInTheDocument(); expect(screen.queryByText(/offline/i)).not.toBeInTheDocument(); }); it('never mounts on onboarding or loading views', () => { backendMocks.fetchServerInfo.mockResolvedValue(updateInfo()); const onboarding = render(); expect(screen.queryByRole('status')).not.toBeInTheDocument(); expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled(); onboarding.unmount(); appStateConfig.initialViewMode = 'loading'; render(); expect(screen.getByText('Loading view')).toBeInTheDocument(); expect(screen.queryByRole('status')).not.toBeInTheDocument(); expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled(); }); it('has a keyboard-focusable dismiss control with an accessible label', async () => { backendMocks.fetchServerInfo.mockResolvedValue(updateInfo()); render(); await connectBackend(); const dismiss = await screen.findByRole('button', { name: 'Dismiss update notification' }); dismiss.focus(); expect(dismiss).toHaveFocus(); await userEvent.keyboard('{Enter}'); expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); it('renders translated copy in zh-CN', async () => { await i18n.changeLanguage('zh-CN'); backendMocks.fetchServerInfo.mockResolvedValue(updateInfo()); render(); await connectBackend(); expect(await screen.findByRole('status')).toHaveTextContent( 'GitNexus 2.0.0 已发布 — 此服务器运行 1.0.0。', ); }); it('never commits an older fetch response over a newer one', async () => { const deferred: Array<(value: ServerInfo) => void> = []; backendMocks.fetchServerInfo.mockImplementation( () => new Promise((resolve) => deferred.push(resolve)), ); render(); await connectBackend(); // A reconnect refetch starts while the connect fetch is still in flight. const [onConnect, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0]; act(() => onReconnecting()); await act(async () => onConnect()); expect(deferred).toHaveLength(2); // The newer fetch resolves first with 2.1.0; the older fetch resolves late with 2.0.0. await act(async () => deferred[1](updateInfo('2.1.0'))); expect(await screen.findByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available'); await act(async () => deferred[0](updateInfo('2.0.0'))); expect(screen.getByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available'); }); it('refetches server info on the slow exploring cadence', async () => { vi.useFakeTimers(); try { backendMocks.fetchServerInfo.mockResolvedValue(updateInfo()); render(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Connect test backend' })); }); expect(screen.getByRole('status')).toHaveTextContent('GitNexus 2.0.0 is available'); expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1); await act(async () => { vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS); }); expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2); await act(async () => { vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS); }); expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(3); } finally { vi.useRealTimers(); } }); it('does not poll server info while the exploring session is disconnected', async () => { vi.useFakeTimers(); try { backendMocks.fetchServerInfo.mockResolvedValue(updateInfo()); render(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Connect test backend' })); }); expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1); const [, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0]; act(() => onReconnecting()); await act(async () => { vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS * 2); }); expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); });