GitNexus/gitnexus-web/test/unit/update-banner.test.tsx
Gergő Magyar 9bf307123e
feat: notify users when a newer gitnexus version is available (#3175)
* feat(core): add cache-first npm update-check service

Shared fail-open checker: validated 24h cache under GITNEXUS_HOME,
acquireFileLock-guarded refresh, monotonic publication, hardened
registry fetch (no credentials, private-address redirects refused,
body-capped), strict x.y.z comparator, install-eligibility
classification, and an unref'd refresh scheduler for long-lived
processes. Extracts getGlobalDir into storage/global-dir.ts with a
repo-manager re-export (no caller changes).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(cli): notify on available updates via stderr and doctor

One i18n'd stderr line on interactive invocations when the validated
cache holds a newer version (TTY-gated, CI/opt-out/eligibility-gated,
hook and help/version command identities excluded). Stale cache spawns
a detached hidden __update-check refresh child so command exit latency
is unchanged. doctor prints the cached latest version when known.
Dockerfile.cli sets GITNEXUS_NO_UPDATE_NOTIFIER=1.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(mcp): emit one stderr update notice per process per version

Process-scoped adapter in mcpCommand (stdio and --http), dynamically
imported after the stdout sentinel, started only after connect, fully
catch-isolated. Arms the shared refresh scheduler with cleanup on
process exit. Protocol payloads stay free of update state (R15).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(serve): expose update state on /api/info

Serve-scoped controller owns an in-memory update snapshot: one
staleness evaluation after listen, then the shared unref'd scheduler,
stopped on close/shutdown. /api/info reads only the snapshot and gains
optional latestVersion/updateAvailable fields for eligible installs;
the existing three fields are byte-compatible.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): dismissible update-available banner from /api/info

Fetches server info after backend connect and on reconnect, renders a
fixed banner in the exploring view only when updateAvailable is true
and the version is undismissed, hides while the reconnect banner is
active, and fails open on fetch errors. role=status + aria-live with a
keyboard-focusable dismiss; dismissal persists per version in
localStorage. Copy in en/zh-CN common.json with version interpolation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(cli): document update notifications and opt-outs

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): apply review findings and simplify pass

Review: gate the detached refresh spawn on a live lock-owner probe so
parallel CLI invocations coalesce to one refresh child (validated P2,
three-reviewer agreement); poll /api/info on a slow cadence while
exploring so post-load server-side discoveries surface (validated P1);
add a monotonic sequence guard so overlapping server-info fetches
commit in order.

Simplify (behavior-preserving): shared truthy-env/opt-out/freshness
helpers in update-cache.ts, shared cachedUpdateNoticeLine for CLI and
doctor, extracted install-eligibility core with per-process memo,
memoized registry parsing, single evaluation per scheduler cycle,
cache-only startup evaluate in serve, flattened MCP exit handler,
shared bottom-banner shell, storage keys in ui-constants.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(update-notifier): address residual review tickets on this PR

Stop the lock-busy 1ms scheduler spin, replace clock-skewed cache
entries, move the outbound URL guard into core, and extract the serve
update controller. Pin the startup/guard/single-flight/MCP/CLI contracts
those tickets called out.

Fixes #3167 #3168 #3169 #3170 #3171 #3172 #3173 #3174

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3175)

Fetch the npm /latest document instead of the full packument so the 64KiB cap can succeed, and treat reused lock PIDs as stale so refresh is not suppressed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3175)

Register the CLI spawn suite on the OS matrix, pin MCP opt-out env, and compare versions without IEEE-754 rounding.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(cli): add gitnexus update install and versioned command banners

Give an explicit Claude/Codex-style upgrade (`npm i -g gitnexus@version`) and print `GitNexus <Name> (version)` on every command so the running build is obvious without silent self-update.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3175)

- Document the pinned install as npm i -g gitnexus@<x.y.z>, not a copyable @version tag
- Wait for wall-clock-future cache repair to publish before asserting
- Restore the stdout spy if the TTY notice assertions fail

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(update-notifier): keep last known latestVersion on a failed refresh

A later offline check was wiping the pin and hiding a known update for 24h.
gitnexus update still treats a failed live fetch as checkFailed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3175)

- Word update.current so a newer-than-latest install is not called the latest stable version.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): hide the detached update-check spawn on Windows

The refresh child was spawned without windowsHide, so Windows CI could stall
before writing the cache and then fail cleanup with EBUSY.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 18:50:03 +01:00

349 lines
12 KiB
TypeScript

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<ServerInfo>>(),
fetchRepos: vi.fn(async () => []),
connectHeartbeat: vi.fn(),
}));
vi.mock('../../src/hooks/useAppState', async () => {
const React = await import('react');
const AppStateContext = React.createContext<Record<string, unknown> | null>(null);
return {
AppStateProvider: ({ children }: { children: React.ReactNode }) => {
const [viewMode, setViewMode] = React.useState(appStateConfig.initialViewMode);
const [serverBaseUrl, setServerBaseUrl] = React.useState<string | null>(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 (
<AppStateContext.Provider
value={{
...stable,
viewMode,
setViewMode,
progress:
viewMode === 'loading'
? { phase: 'extracting', percent: 1, message: 'Loading' }
: null,
isRightPanelOpen: false,
isSettingsPanelOpen: false,
codeReferences: [],
selectedNode: null,
isCodePanelOpen: false,
serverBaseUrl,
setServerBaseUrl,
availableRepos: [],
}}
>
{children}
</AppStateContext.Provider>
);
},
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<void>;
}) => (
<button onClick={() => void onServerConnect(connectResult, 'http://localhost:4747')}>
Connect test backend
</button>
),
}));
vi.mock('../../src/components/LoadingOverlay', () => ({
LoadingOverlay: () => <div>Loading view</div>,
}));
vi.mock('../../src/components/Header', () => ({ Header: () => <header>Header</header> }));
vi.mock('../../src/components/GraphCanvas', async () => {
const React = await import('react');
return { GraphCanvas: React.forwardRef(() => <div>Graph</div>) };
});
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<typeof import('../../src/services/backend-client')>();
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
onboarding.unmount();
appStateConfig.initialViewMode = 'loading';
render(<App />);
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(<App />);
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(<App />);
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<ServerInfo>((resolve) => deferred.push(resolve)),
);
render(<App />);
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(<App />);
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(<App />);
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();
}
});
});