diff --git a/gitnexus-web/e2e/server-connect.spec.ts b/gitnexus-web/e2e/server-connect.spec.ts
index eb241da10..0705b3a27 100644
--- a/gitnexus-web/e2e/server-connect.spec.ts
+++ b/gitnexus-web/e2e/server-connect.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect, type TestInfo } from '@playwright/test';
+import { test, expect } from '@playwright/test';
/**
* E2E tests for the GitNexus web UI — exploring view features.
@@ -58,36 +58,41 @@ test.beforeAll(async () => {
* For these tests we require at least one indexed repo, so pick the first
* landing card when present and then wait for the exploring view.
*/
-async function waitForGraphLoaded(page: import('@playwright/test').Page, testInfo: TestInfo) {
+async function waitForGraphLoaded(page: import('@playwright/test').Page) {
await page.goto('/');
- const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
+ const landingCards = page.locator('[data-testid="landing-repo-card"]');
+ const preferredLandingCard = landingCards
+ .filter({ hasText: /GitNexus|local-integration/ })
+ .first();
try {
- await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
+ await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 });
+ const landingCard =
+ (await preferredLandingCard.count()) > 0 ? preferredLandingCard : landingCards.first();
await landingCard.click();
} catch {
// Landing screen may not appear (e.g. ?server auto-connect)
}
- await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
- await expect(page.getByText(/\d+ nodes/).first()).toBeVisible();
- await page.screenshot({ path: testInfo.outputPath('graph-loaded.png') });
+ const statusBar = page.getByRole('contentinfo');
+ await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 });
+ await expect(statusBar).toContainText(/nodes/, {
+ timeout: 20_000,
+ });
}
test.describe('Server Connection & Graph Loading', () => {
- test('selects a repo from landing and loads graph', async ({ page }, testInfo) => {
- await waitForGraphLoaded(page, testInfo);
- await page.screenshot({ path: testInfo.outputPath('graph-loaded-full.png'), fullPage: true });
+ test('selects a repo from landing and loads graph', async ({ page }) => {
+ await waitForGraphLoaded(page);
});
});
test.describe('Nexus AI', () => {
- test('panel opens and agent initializes without error', async ({ page }, testInfo) => {
- await waitForGraphLoaded(page, testInfo);
+ test('panel opens and agent initializes without error', async ({ page }) => {
+ await waitForGraphLoaded(page);
await page.getByRole('button', { name: 'Nexus AI' }).click();
await expect(page.getByText('Ask me anything')).toBeVisible({ timeout: 15_000 });
- await page.screenshot({ path: testInfo.outputPath('nexus-ai-panel.png'), fullPage: true });
const errorBanner = page.getByText('Database not ready');
expect(await errorBanner.isVisible().catch(() => false)).toBe(false);
@@ -95,8 +100,8 @@ test.describe('Nexus AI', () => {
});
test.describe('Processes Panel', () => {
- test('shows process list and View button works', async ({ page }, testInfo) => {
- await waitForGraphLoaded(page, testInfo);
+ test('shows process list and View button works', async ({ page }) => {
+ await waitForGraphLoaded(page);
await page.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').click();
@@ -104,7 +109,6 @@ test.describe('Processes Panel', () => {
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({
timeout: 15_000,
});
- await page.screenshot({ path: testInfo.outputPath('processes-panel.png'), fullPage: true });
const processRow = page.locator('[data-testid="process-row"]').first();
await expect(processRow).toBeVisible({ timeout: 10_000 });
@@ -114,14 +118,10 @@ test.describe('Processes Panel', () => {
await viewBtn.waitFor({ state: 'visible', timeout: 5_000 });
await viewBtn.click();
await expect(page.locator('[data-testid="process-modal"]')).toBeVisible({ timeout: 5_000 });
- await page.screenshot({
- path: testInfo.outputPath('process-view-clicked.png'),
- fullPage: true,
- });
});
- test('lightbulb highlights nodes in graph', async ({ page }, testInfo) => {
- await waitForGraphLoaded(page, testInfo);
+ test('lightbulb highlights nodes in graph', async ({ page }) => {
+ await waitForGraphLoaded(page);
await page.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').click();
@@ -137,13 +137,12 @@ test.describe('Processes Panel', () => {
await lightbulb.waitFor({ state: 'visible', timeout: 5_000 });
await lightbulb.click();
await expect(processRow).toHaveClass(/bg-amber-950/, { timeout: 5_000 });
- await page.screenshot({ path: testInfo.outputPath('after-highlight.png'), fullPage: true });
});
});
test.describe('Turn Off All Highlights', () => {
- test('selecting a node dims others, button clears it', async ({ page }, testInfo) => {
- await waitForGraphLoaded(page, testInfo);
+ test('selecting a node dims others, button clears it', async ({ page }) => {
+ await waitForGraphLoaded(page);
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 10_000 });
@@ -160,6 +159,5 @@ test.describe('Turn Off All Highlights', () => {
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', {
timeout: 5_000,
});
- await page.screenshot({ path: testInfo.outputPath('highlights-cleared.png'), fullPage: true });
});
});
diff --git a/gitnexus-web/src/components/StatusBar.tsx b/gitnexus-web/src/components/StatusBar.tsx
index a618c3c1c..7468072fa 100644
--- a/gitnexus-web/src/components/StatusBar.tsx
+++ b/gitnexus-web/src/components/StatusBar.tsx
@@ -64,7 +64,7 @@ export const StatusBar = () => {
{/* Right - Stats */}
-
+
{graph && (
<>
{nodeCount} nodes
diff --git a/gitnexus-web/test/unit/server-connection.test.ts b/gitnexus-web/test/unit/server-connection.test.ts
index ccbecd307..f5ee43c53 100644
--- a/gitnexus-web/test/unit/server-connection.test.ts
+++ b/gitnexus-web/test/unit/server-connection.test.ts
@@ -1,9 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-import {
- fetchGraph,
- normalizeServerUrl,
- setBackendUrl,
-} from '../../src/services/backend-client';
+import { fetchGraph, normalizeServerUrl, setBackendUrl } from '../../src/services/backend-client';
describe('normalizeServerUrl', () => {
it('adds http:// to localhost', () => {
diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts
index c0e34e927..8111c287b 100644
--- a/gitnexus/src/server/api.ts
+++ b/gitnexus/src/server/api.ts
@@ -127,19 +127,13 @@ export const isIgnorableGraphQueryError = (err: unknown): boolean => {
);
};
-const ensureStreamIsWritable = (
- res: express.Response,
- signal?: AbortSignal,
-): void => {
+const ensureStreamIsWritable = (res: express.Response, signal?: AbortSignal): void => {
if (signal?.aborted || res.destroyed || res.writableEnded) {
throw new ClientDisconnectedError();
}
};
-const waitForDrain = async (
- res: express.Response,
- signal?: AbortSignal,
-): Promise => {
+const waitForDrain = async (res: express.Response, signal?: AbortSignal): Promise => {
ensureStreamIsWritable(res, signal);
await new Promise((resolve, reject) => {
@@ -234,24 +228,34 @@ const GRAPH_RELATIONSHIP_QUERY =
`MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, ` +
`r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`;
+const quoteNodeTable = (table: string): string => `\`${table.replace(/`/g, '``')}\``;
+
const getNodeQuery = (table: string, includeContent: boolean): string => {
+ const tableLabel = quoteNodeTable(table);
+
if (table === 'File') {
return includeContent
- ? `MATCH (n:File) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.content AS content`
- : `MATCH (n:File) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
+ ? `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.content AS content`
+ : `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
}
if (table === 'Folder') {
- return `MATCH (n:Folder) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath`;
}
if (table === 'Community') {
- return `MATCH (n:Community) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.cohesion AS cohesion, n.symbolCount AS symbolCount`;
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.cohesion AS cohesion, n.symbolCount AS symbolCount`;
}
if (table === 'Process') {
- return `MATCH (n:Process) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.processType AS processType, n.stepCount AS stepCount, n.communities AS communities, n.entryPointId AS entryPointId, n.terminalId AS terminalId`;
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.label AS label, n.heuristicLabel AS heuristicLabel, n.processType AS processType, n.stepCount AS stepCount, n.communities AS communities, n.entryPointId AS entryPointId, n.terminalId AS terminalId`;
+ }
+ if (table === 'Route') {
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware`;
+ }
+ if (table === 'Tool') {
+ return `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.description AS description`;
}
return includeContent
- ? `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`
- : `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
+ ? `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`
+ : `MATCH (n:${tableLabel}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
};
const mapGraphNodeRow = (table: string, row: any, includeContent: boolean): GraphNode => ({
@@ -263,9 +267,13 @@ const mapGraphNodeRow = (table: string, row: any, includeContent: boolean): Grap
startLine: row.startLine,
endLine: row.endLine,
content: includeContent ? row.content : undefined,
+ responseKeys: row.responseKeys,
+ errorKeys: row.errorKeys,
+ middleware: row.middleware,
heuristicLabel: row.heuristicLabel,
cohesion: row.cohesion,
symbolCount: row.symbolCount,
+ description: row.description,
processType: row.processType,
stepCount: row.stepCount,
communities: row.communities,
diff --git a/gitnexus/test/unit/api-graph-streaming.test.ts b/gitnexus/test/unit/api-graph-streaming.test.ts
index 2d22b5ad9..0cc69833c 100644
--- a/gitnexus/test/unit/api-graph-streaming.test.ts
+++ b/gitnexus/test/unit/api-graph-streaming.test.ts
@@ -12,10 +12,7 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => {
return { ...actual, ...lbugMocks };
});
-import {
- ClientDisconnectedError,
- streamGraphNdjson,
-} from '../../src/server/api.js';
+import { ClientDisconnectedError, streamGraphNdjson } from '../../src/server/api.js';
const createMockResponse = (writeImpl?: (chunk: string) => boolean) => {
const response = new EventEmitter() as any;
@@ -31,21 +28,23 @@ describe('streamGraphNdjson', () => {
});
it('waits for drain when writes hit backpressure', async () => {
- lbugMocks.streamQuery.mockImplementation(async (query: string, onRow: (row: any) => Promise) => {
- if (query.includes('MATCH (n:File)')) {
- await onRow({ id: 'File:src/app.ts', name: 'app.ts', filePath: 'src/app.ts' });
- return 1;
- }
- if (query.includes('CodeRelation')) {
- await onRow({
- sourceId: 'File:src/app.ts',
- targetId: 'Function:src/app.ts:main',
- type: 'CONTAINS',
- });
- return 1;
- }
- return 0;
- });
+ lbugMocks.streamQuery.mockImplementation(
+ async (query: string, onRow: (row: any) => Promise) => {
+ if (query.includes('MATCH (n:`File`)')) {
+ await onRow({ id: 'File:src/app.ts', name: 'app.ts', filePath: 'src/app.ts' });
+ return 1;
+ }
+ if (query.includes('CodeRelation')) {
+ await onRow({
+ sourceId: 'File:src/app.ts',
+ targetId: 'Function:src/app.ts:main',
+ type: 'CONTAINS',
+ });
+ return 1;
+ }
+ return 0;
+ },
+ );
const writes: string[] = [];
let firstWrite = true;
@@ -75,15 +74,17 @@ describe('streamGraphNdjson', () => {
it('stops streaming when the client disconnects', async () => {
const controller = new AbortController();
- lbugMocks.streamQuery.mockImplementation(async (query: string, onRow: (row: any) => Promise) => {
- if (!query.includes('MATCH (n:File)')) {
- return 0;
- }
- await onRow({ id: 'File:src/app.ts', name: 'app.ts', filePath: 'src/app.ts' });
- controller.abort();
- await onRow({ id: 'File:src/other.ts', name: 'other.ts', filePath: 'src/other.ts' });
- return 2;
- });
+ lbugMocks.streamQuery.mockImplementation(
+ async (query: string, onRow: (row: any) => Promise) => {
+ if (!query.includes('MATCH (n:`File`)')) {
+ return 0;
+ }
+ await onRow({ id: 'File:src/app.ts', name: 'app.ts', filePath: 'src/app.ts' });
+ controller.abort();
+ await onRow({ id: 'File:src/other.ts', name: 'other.ts', filePath: 'src/other.ts' });
+ return 2;
+ },
+ );
const response = createMockResponse();
@@ -95,7 +96,7 @@ describe('streamGraphNdjson', () => {
it('rethrows non-missing table errors', async () => {
lbugMocks.streamQuery.mockImplementation(async (query: string) => {
- if (query.includes('MATCH (n:File)')) {
+ if (query.includes('MATCH (n:`File`)')) {
throw new Error('database unavailable');
}
return 0;
@@ -106,23 +107,129 @@ describe('streamGraphNdjson', () => {
});
it('ignores missing-table errors while continuing the stream', async () => {
- lbugMocks.streamQuery.mockImplementation(async (query: string, onRow: (row: any) => Promise) => {
- if (query.includes('MATCH (n:File)')) {
- throw new Error('Table File does not exist');
- }
- if (query.includes('CodeRelation')) {
- await onRow({
- sourceId: 'File:src/app.ts',
- targetId: 'Function:src/app.ts:main',
- type: 'CONTAINS',
- });
- return 1;
- }
- return 0;
- });
+ lbugMocks.streamQuery.mockImplementation(
+ async (query: string, onRow: (row: any) => Promise) => {
+ if (query.includes('MATCH (n:`File`)')) {
+ throw new Error('Table File does not exist');
+ }
+ if (query.includes('CodeRelation')) {
+ await onRow({
+ sourceId: 'File:src/app.ts',
+ targetId: 'Function:src/app.ts:main',
+ type: 'CONTAINS',
+ });
+ return 1;
+ }
+ return 0;
+ },
+ );
const response = createMockResponse();
await expect(streamGraphNdjson(response, false)).resolves.toBeUndefined();
expect(response.write).toHaveBeenCalledTimes(1);
});
+
+ it('quotes node table names in generated Cypher queries', async () => {
+ lbugMocks.streamQuery.mockImplementation(async () => 0);
+
+ const response = createMockResponse();
+ await expect(streamGraphNdjson(response, false)).resolves.toBeUndefined();
+
+ expect(lbugMocks.streamQuery).toHaveBeenCalledWith(
+ expect.stringContaining('MATCH (n:`Macro`)'),
+ expect.any(Function),
+ );
+ });
+
+ it('streams Route and Tool nodes without requiring startLine fields', async () => {
+ lbugMocks.streamQuery.mockImplementation(
+ async (query: string, onRow: (row: any) => Promise) => {
+ if (query.includes('MATCH (n:`Route`)')) {
+ expect(query).not.toContain('startLine');
+ await onRow({
+ id: 'Route:/api/graph:GET',
+ name: 'GET /api/graph',
+ filePath: 'src/server/api.ts',
+ responseKeys: ['nodes', 'relationships'],
+ errorKeys: ['error'],
+ middleware: ['withAuth'],
+ });
+ return 1;
+ }
+ if (query.includes('MATCH (n:`Tool`)')) {
+ expect(query).not.toContain('startLine');
+ await onRow({
+ id: 'Tool:gitnexus_query',
+ name: 'gitnexus_query',
+ filePath: 'src/mcp/resources.ts',
+ description: 'Query the code graph',
+ });
+ return 1;
+ }
+ return 0;
+ },
+ );
+
+ const writes: string[] = [];
+ const response = createMockResponse((chunk) => {
+ writes.push(chunk);
+ return true;
+ });
+
+ await expect(streamGraphNdjson(response, false)).resolves.toBeUndefined();
+
+ const records = writes.map((chunk) => JSON.parse(chunk));
+ expect(records).toContainEqual({
+ type: 'node',
+ data: {
+ id: 'Route:/api/graph:GET',
+ label: 'Route',
+ properties: {
+ name: 'GET /api/graph',
+ filePath: 'src/server/api.ts',
+ startLine: undefined,
+ endLine: undefined,
+ content: undefined,
+ responseKeys: ['nodes', 'relationships'],
+ errorKeys: ['error'],
+ middleware: ['withAuth'],
+ heuristicLabel: undefined,
+ cohesion: undefined,
+ symbolCount: undefined,
+ description: undefined,
+ processType: undefined,
+ stepCount: undefined,
+ communities: undefined,
+ entryPointId: undefined,
+ terminalId: undefined,
+ },
+ },
+ });
+ expect(records).toContainEqual({
+ type: 'node',
+ data: {
+ id: 'Tool:gitnexus_query',
+ label: 'Tool',
+ properties: {
+ name: 'gitnexus_query',
+ filePath: 'src/mcp/resources.ts',
+ startLine: undefined,
+ endLine: undefined,
+ content: undefined,
+ responseKeys: undefined,
+ errorKeys: undefined,
+ middleware: undefined,
+ heuristicLabel: undefined,
+ cohesion: undefined,
+ symbolCount: undefined,
+ description: 'Query the code graph',
+ processType: undefined,
+ stepCount: undefined,
+ communities: undefined,
+ entryPointId: undefined,
+ terminalId: undefined,
+ },
+ },
+ });
+ });
});