Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83551caa3e | ||
|
|
6bc8c93c55 | ||
|
|
641615dae4 | ||
|
|
10388e43f1 | ||
|
|
f9f074b67d | ||
|
|
a6c66f9777 |
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for multi-repo scoping and URL persistence.
|
||||
*
|
||||
* Verifies that:
|
||||
* - Connecting via ?server= loads data and sets ?project= in the URL
|
||||
* - The repo name appears in the UI after connecting
|
||||
* - F5 with ?server=&project= reconnects to the correct repo
|
||||
*
|
||||
* Runs against the single indexed repo in CI — validates the plumbing
|
||||
* works end-to-end even with one repo.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
|
||||
|
||||
let firstRepoName: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) {
|
||||
// Still need to fetch the repo name for assertions
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/repos`);
|
||||
const repos = await res.json();
|
||||
firstRepoName = repos[0]?.name ?? '';
|
||||
} catch {
|
||||
firstRepoName = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [backendRes, frontendRes] = await Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/api/repos`),
|
||||
fetch(FRONTEND_URL),
|
||||
]);
|
||||
if (
|
||||
backendRes.status === 'rejected' ||
|
||||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'gitnexus serve not available');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
frontendRes.status === 'rejected' ||
|
||||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'Vite dev server not available');
|
||||
return;
|
||||
}
|
||||
if (backendRes.status === 'fulfilled') {
|
||||
const repos = await backendRes.value.json();
|
||||
if (!repos.length) {
|
||||
test.skip(true, 'No indexed repos');
|
||||
return;
|
||||
}
|
||||
firstRepoName = repos[0].name;
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, 'servers not available');
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Multi-Repo Scoping', () => {
|
||||
test('auto-connect via ?server= sets ?project= in URL', async ({ page }) => {
|
||||
// Navigate with ?server= param (the bookmarkable shortcut)
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
|
||||
// Wait for graph to load
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// URL should now contain ?project= with the repo name
|
||||
const url = new URL(page.url());
|
||||
const project = url.searchParams.get('project');
|
||||
expect(project).toBeTruthy();
|
||||
expect(project).toBe(firstRepoName);
|
||||
});
|
||||
|
||||
test('?server= is preserved in URL for F5 recovery', async ({ page }) => {
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// URL should still have ?server=
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('server')).toBeTruthy();
|
||||
|
||||
// F5 should reconnect (not show onboarding)
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('node count in status bar matches backend data', async ({ page }) => {
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Fetch expected node count from backend
|
||||
const res = await fetch(`${BACKEND_URL}/api/repo?repo=${encodeURIComponent(firstRepoName)}`);
|
||||
const repoInfo = await res.json();
|
||||
const expectedNodes = repoInfo.stats?.nodes;
|
||||
|
||||
if (expectedNodes) {
|
||||
// Status bar shows node count — use the status-ready area to avoid
|
||||
// matching multiple elements (file tree, header may also show counts)
|
||||
const statusBar = page.locator('footer');
|
||||
const nodeText = statusBar.getByText(/\d+ nodes/).first();
|
||||
await expect(nodeText).toBeVisible({ timeout: 10_000 });
|
||||
const text = await nodeText.textContent();
|
||||
const displayedNodes = parseInt(text?.match(/(\d+)\s*nodes/)?.[1] ?? '0', 10);
|
||||
expect(displayedNodes).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
+65
-37
@@ -45,17 +45,26 @@ const AppContent = () => {
|
||||
availableRepos,
|
||||
setAvailableRepos,
|
||||
switchRepo,
|
||||
setCurrentRepo,
|
||||
} = useAppState();
|
||||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
|
||||
const handleServerConnect = useCallback(
|
||||
async (result: ConnectResult): Promise<void> => {
|
||||
// Extract project name from repoPath
|
||||
// Use the canonical repo name from the server response so all subsequent
|
||||
// backend calls (queries, search, grep, readFile) scope to this repo.
|
||||
const repoName = result.repoInfo.name;
|
||||
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
|
||||
const parts = (repoPath || '').split('/').filter((p) => p && !p.startsWith('.'));
|
||||
const projectName = parts[parts.length - 1] || parts[0] || 'server-project';
|
||||
const projectName =
|
||||
repoName || repoPath?.split('/').filter(Boolean).pop() || 'server-project';
|
||||
setProjectName(projectName);
|
||||
setCurrentRepo(projectName);
|
||||
|
||||
// Update URL so F5 / bookmarks preserve which repo is open
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('project', projectName);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
|
||||
// Build KnowledgeGraph from server data for visualization
|
||||
const graph = createKnowledgeGraph();
|
||||
@@ -80,10 +89,18 @@ const AppContent = () => {
|
||||
console.warn('Failed to initialize agent:', err);
|
||||
}
|
||||
},
|
||||
[setViewMode, setGraph, setProjectName, initializeAgent, startEmbeddingsWithFallback],
|
||||
[
|
||||
setViewMode,
|
||||
setGraph,
|
||||
setProjectName,
|
||||
setCurrentRepo,
|
||||
initializeAgent,
|
||||
startEmbeddingsWithFallback,
|
||||
],
|
||||
);
|
||||
|
||||
// Auto-connect when ?server query param is present (bookmarkable shortcut)
|
||||
// Auto-connect when ?server query param is present (bookmarkable shortcut).
|
||||
// Also reads ?project= to connect to a specific repo.
|
||||
const autoConnectRan = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoConnectRan.current) return;
|
||||
@@ -91,9 +108,12 @@ const AppContent = () => {
|
||||
if (!params.has('server')) return;
|
||||
autoConnectRan.current = true;
|
||||
|
||||
// Clean the URL so a refresh won't re-trigger
|
||||
const cleanUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
const serverUrl = params.get('server') || window.location.origin;
|
||||
const projectParam = params.get('project') || undefined;
|
||||
|
||||
// Keep ?server= in the URL so F5 reconnects to the same server.
|
||||
// autoConnectRan.current prevents re-trigger within the same session.
|
||||
// handleServerConnect() will add/update ?project= after connecting.
|
||||
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
@@ -103,36 +123,39 @@ const AppContent = () => {
|
||||
});
|
||||
setViewMode('loading');
|
||||
|
||||
const serverUrl = params.get('server') || window.location.origin;
|
||||
|
||||
const baseUrl = normalizeServerUrl(serverUrl);
|
||||
|
||||
connectToServer(serverUrl, (phase, downloaded, total) => {
|
||||
if (phase === 'validating') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 5,
|
||||
message: 'Connecting to server...',
|
||||
detail: 'Validating server',
|
||||
});
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: pct,
|
||||
message: 'Downloading graph...',
|
||||
detail: `${mb} MB downloaded`,
|
||||
});
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 97,
|
||||
message: 'Processing...',
|
||||
detail: 'Extracting file contents',
|
||||
});
|
||||
}
|
||||
})
|
||||
connectToServer(
|
||||
serverUrl,
|
||||
(phase, downloaded, total) => {
|
||||
if (phase === 'validating') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 5,
|
||||
message: 'Connecting to server...',
|
||||
detail: 'Validating server',
|
||||
});
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: pct,
|
||||
message: 'Downloading graph...',
|
||||
detail: `${mb} MB downloaded`,
|
||||
});
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 97,
|
||||
message: 'Processing...',
|
||||
detail: 'Extracting file contents',
|
||||
});
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
projectParam,
|
||||
)
|
||||
.then(async (result) => {
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
@@ -196,7 +219,12 @@ const AppContent = () => {
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
if (serverUrl) {
|
||||
setServerBaseUrl(normalizeServerUrl(serverUrl));
|
||||
const base = normalizeServerUrl(serverUrl);
|
||||
setServerBaseUrl(base);
|
||||
// Add ?server= so F5 reconnects to this server
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('server', base);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -54,6 +54,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
clearCodeReferences,
|
||||
setSelectedNode,
|
||||
codeReferenceFocus,
|
||||
projectName,
|
||||
} = useAppState();
|
||||
|
||||
const nodeById = useMemo(() => {
|
||||
@@ -174,7 +175,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
return () => {
|
||||
rafIds.forEach((id) => cancelAnimationFrame(id));
|
||||
};
|
||||
}, [codeReferenceFocus?.ts, aiReferences]);
|
||||
}, [codeReferenceFocus, aiReferences]);
|
||||
|
||||
const refsWithSnippets = useMemo(() => {
|
||||
return aiReferences.map((ref) => {
|
||||
@@ -223,10 +224,11 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
const isWholeFile = selectedIsFile || startLine === undefined;
|
||||
|
||||
const options = isWholeFile
|
||||
? undefined
|
||||
? { repo: projectName }
|
||||
: {
|
||||
startLine: Math.max(0, startLine - CONTEXT_LINES),
|
||||
endLine: (endLine ?? startLine) + CONTEXT_LINES,
|
||||
repo: projectName,
|
||||
};
|
||||
|
||||
readFile(selectedFilePath, options)
|
||||
@@ -251,6 +253,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
selectedNode?.properties?.startLine,
|
||||
selectedNode?.properties?.endLine,
|
||||
selectedIsFile,
|
||||
projectName,
|
||||
]);
|
||||
|
||||
// Scroll to the selected node's startLine after content loads
|
||||
|
||||
@@ -145,6 +145,7 @@ interface AppState {
|
||||
availableRepos: BackendRepo[];
|
||||
setAvailableRepos: (repos: BackendRepo[]) => void;
|
||||
switchRepo: (repoName: string) => Promise<void>;
|
||||
setCurrentRepo: (repoName: string) => void;
|
||||
|
||||
// Worker API (shared across app)
|
||||
runQuery: (cypher: string) => Promise<any[]>;
|
||||
@@ -456,6 +457,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
// Backend client — direct HTTP calls (no Worker/Comlink)
|
||||
const repoRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const setCurrentRepo = useCallback((repoName: string) => {
|
||||
repoRef.current = repoName;
|
||||
}, []);
|
||||
|
||||
const runQuery = useCallback(async (cypher: string): Promise<any[]> => {
|
||||
return backendRunQuery(cypher, repoRef.current);
|
||||
}, []);
|
||||
@@ -1077,6 +1082,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
setProjectName(pName);
|
||||
repoRef.current = pName;
|
||||
|
||||
// Update URL so F5 / bookmarks open the correct repo
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('project', pName);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
|
||||
const newGraph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) newGraph.addNode(node);
|
||||
for (const rel of result.relationships) newGraph.addRelationship(rel);
|
||||
@@ -1219,6 +1229,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
availableRepos,
|
||||
setAvailableRepos,
|
||||
switchRepo,
|
||||
setCurrentRepo,
|
||||
runQuery,
|
||||
isDatabaseReady,
|
||||
// Embedding state and methods
|
||||
|
||||
Reference in New Issue
Block a user