import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Header } from '../../src/components/Header';
import {
deleteRepo,
fetchRepos,
startAnalyze,
streamAnalyzeProgress,
} from '../../src/services/backend-client';
import type { BackendRepo } from '../../src/services/backend-client';
vi.mock('../../src/hooks/useAppState', () => ({
useAppState: () => ({
projectName: 'reels',
currentRepo: '/workspace/group-b/reels',
graph: null,
graphMode: 'full',
openChatPanel: vi.fn(),
isRightPanelOpen: false,
rightPanelTab: 'chat',
setSettingsPanelOpen: vi.fn(),
setHelpDialogBoxOpen: vi.fn(),
}),
}));
vi.mock('../../src/components/EmbeddingStatus', () => ({
EmbeddingStatus: () =>
,
}));
vi.mock('../../src/components/LanguageSwitcher', () => ({
LanguageSwitcher: () => ,
}));
vi.mock('../../src/components/RepoAnalyzer', () => ({
RepoAnalyzer: () => ,
}));
vi.mock('../../src/services/backend-client', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
deleteRepo: vi.fn(),
fetchRepos: vi.fn(),
startAnalyze: vi.fn(),
streamAnalyzeProgress: vi.fn(),
};
});
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record) => {
if (key === 'header:repositories') return 'Repositories';
if (key === 'header:active') return 'Active';
if (key === 'header:reanalyzeRepo') return `Re-analyze ${options?.repoName ?? ''}`;
if (key === 'header:reanalyzingRepo')
return `Re-analyzing ${options?.repoName ?? ''}: ${options?.message ?? ''}`;
if (key === 'header:deleteRepo') return `Delete ${options?.repoName ?? ''}`;
if (key === 'header:analyzeNew') return 'Analyze new';
if (key === 'header:searchRepositories') return 'Search repositories...';
if (key === 'header:noRepositoriesFound')
return `No repositories found for ${options?.query}`;
return key;
},
}),
}));
function makeRepo(index: number): BackendRepo {
return {
name: index === 0 ? 'reels' : `repo-${index}`,
path: `/tmp/repo-${index}`,
stats: {
files: 1,
nodes: 1,
edges: 0,
communities: 0,
processes: 0,
},
};
}
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
// Reset the URL mutated by the delete handler's hygiene pass.
window.history.replaceState(null, '', '/');
});
it('keeps a large repository menu scrollable inside the viewport', () => {
render( makeRepo(index))} />);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
const menu = screen.getByText('Repositories').closest('.absolute');
expect(menu).not.toBeNull();
expect(menu).toHaveClass('max-h-[calc(100vh-4.5rem)]');
expect(menu).toHaveClass('overflow-hidden');
const scrollableRepoList = screen.getByText('repo-29').closest('.scrollbar-thin');
expect(scrollableRepoList).not.toBeNull();
expect(scrollableRepoList).toHaveClass('overflow-y-auto');
expect(scrollableRepoList).toHaveClass('flex-1');
});
it('filters repositories locally by displayed name', async () => {
const user = userEvent.setup();
render(
,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
const input = screen.getByRole('textbox', { name: 'Search repositories...' });
await user.type(input, 'gitnexus');
expect(screen.getByText('gitnexus-web')).toBeInTheDocument();
expect(screen.queryByText('api-server')).not.toBeInTheDocument();
await user.clear(input);
await user.type(input, 'api');
expect(screen.getByText('api-server')).toBeInTheDocument();
expect(screen.queryByText('gitnexus-web')).not.toBeInTheDocument();
});
it('shows an empty state when no repositories match the local search', async () => {
const user = userEvent.setup();
render( makeRepo(index))} />);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await user.type(screen.getByRole('textbox', { name: 'Search repositories...' }), 'missing');
expect(screen.getByText('No repositories found for missing')).toBeInTheDocument();
expect(screen.queryByText('repo-1')).not.toBeInTheDocument();
});
it('does not leave stale rows when duplicate repository names are filtered', async () => {
const user = userEvent.setup();
render(
,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await user.type(screen.getByRole('textbox', { name: 'Search repositories...' }), 'tab');
const repoList = screen.getAllByText('tab_server')[0].closest('.scrollbar-thin');
expect(repoList).not.toBeNull();
expect(repoList).toHaveTextContent('tab_server');
expect(repoList).not.toHaveTextContent('search_sync');
expect(repoList).not.toHaveTextContent('feed_sync');
expect(repoList).not.toHaveTextContent('reels');
});
it('uses repository path identity when duplicate display names are present', async () => {
const onSwitchRepo = vi.fn();
render(
,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
expect(screen.getAllByText('Active')).toHaveLength(1);
await userEvent.click(screen.getAllByText('reels')[1]);
expect(onSwitchRepo).toHaveBeenCalledWith('/workspace/group-a/reels');
});
it('deletes and falls back using repository path identity', async () => {
const onSwitchRepo = vi.fn();
const updatedRepos = [{ ...makeRepo(2), name: 'reels', path: '/workspace/group-a/reels' }];
vi.mocked(fetchRepos).mockResolvedValue(updatedRepos);
render(
,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await userEvent.click(screen.getAllByTitle('Delete reels')[1]);
expect(deleteRepo).toHaveBeenCalledWith('/workspace/group-b/reels');
expect(onSwitchRepo).toHaveBeenCalledWith('/workspace/group-a/reels');
});
it('strips repo, project and skipGraph from the URL before reloading after the last repo is deleted', async () => {
window.history.replaceState(
null,
'',
'/?repo=%2Fworkspace%2Fgroup-b%2Freels&project=reels&skipGraph=1',
);
// jsdom's location.reload is own+non-configurable — replace the whole
// `location` accessor with a stub that delegates URL reads to the real
// Location (kept live by history.replaceState) and mocks reload.
const realLocation = window.location;
const reloadMock = vi.fn();
vi.stubGlobal('location', {
get href() {
return realLocation.href;
},
get search() {
return realLocation.search;
},
reload: reloadMock,
});
vi.mocked(fetchRepos).mockResolvedValue([]);
render(
,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await userEvent.click(screen.getByTitle('Delete reels'));
expect(reloadMock).toHaveBeenCalledTimes(1);
expect(window.location.search).not.toContain('repo=');
expect(window.location.search).not.toContain('project=');
expect(window.location.search).not.toContain('skipGraph');
});
it('strips repo and project from the URL before falling back after deleting the active repo', async () => {
window.history.replaceState(null, '', '/?repo=%2Fworkspace%2Fgroup-b%2Freels&project=reels');
// Capture the URL at the moment of the fallback switch — the stale
// identity must already be gone so a failed switch leaves nothing that
// restores the deleted repo on refresh (#2419).
const searchAtSwitch: string[] = [];
const onSwitchRepo = vi.fn(() => {
searchAtSwitch.push(window.location.search);
});
vi.mocked(fetchRepos).mockResolvedValue([
{ ...makeRepo(2), name: 'reels', path: '/workspace/group-a/reels' },
]);
render(
,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await userEvent.click(screen.getAllByTitle('Delete reels')[1]);
expect(deleteRepo).toHaveBeenCalledWith('/workspace/group-b/reels');
expect(onSwitchRepo).toHaveBeenCalledWith('/workspace/group-a/reels');
expect(searchAtSwitch).toEqual(['']);
});
it('shows the display name, not the path identity, in the re-analyze progress label', async () => {
vi.mocked(startAnalyze).mockResolvedValue({ jobId: 'job-1', status: 'running' });
vi.mocked(streamAnalyzeProgress).mockReturnValue(new AbortController());
render(
,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
// Re-analyze the second duplicate-name row — `reanalyzing` becomes the
// path identity '/ws/b/reels', but the label must render the name.
await userEvent.click(screen.getAllByTitle('Re-analyze reels')[1]);
const label = screen.getByText(/^Re-analyzing /);
expect(label.textContent).toMatch(/^Re-analyzing reels:/);
expect(label.textContent).not.toContain('/ws/b/reels');
});
it('falls back to the path basename when the re-analyzing identity is no longer listed', async () => {
vi.mocked(startAnalyze).mockResolvedValue({ jobId: 'job-2', status: 'running' });
vi.mocked(streamAnalyzeProgress).mockReturnValue(new AbortController());
const { rerender } = render(
,
);
fireEvent.click(screen.getByRole('button', { name: /reels/i }));
await userEvent.click(screen.getByTitle('Re-analyze reels'));
// The repo list refreshes while the re-analysis is still in flight and the
// identity disappears from it — the label degrades to the path basename.
rerender();
const label = screen.getByText(/^Re-analyzing /);
expect(label.textContent).toMatch(/^Re-analyzing reels:/);
expect(label.textContent).not.toContain('/ws/b/reels');
});
});