mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* 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>
349 lines
12 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|