From 391fe0e6c88b12ad4635c4628dfda807da1acd34 Mon Sep 17 00:00:00 2001 From: gfwangjie Date: Thu, 9 Apr 2026 20:01:59 +0800 Subject: [PATCH] fix(ci): stabilize graph loading coverage --- gitnexus-web/e2e/server-connect.spec.ts | 50 +++-- gitnexus-web/src/components/StatusBar.tsx | 2 +- .../test/unit/server-connection.test.ts | 6 +- gitnexus/src/server/api.ts | 38 ++-- .../test/unit/api-graph-streaming.test.ts | 193 ++++++++++++++---- 5 files changed, 199 insertions(+), 90 deletions(-) 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, + }, + }, + }); + }); });