diff --git a/packages/api/src/__tests__/graph-route.test.ts b/packages/api/src/__tests__/graph-route.test.ts index 846feb0..5c98bfd 100644 --- a/packages/api/src/__tests__/graph-route.test.ts +++ b/packages/api/src/__tests__/graph-route.test.ts @@ -302,6 +302,32 @@ describe('GET /api/graph/full unavailable storage', () => { storage: blockedSetupStatus.storage, }); }); + + it('preserves the requested compact edge format while storage is blocked', async () => { + mockedFullGraph.mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:16379')); + mockedGetSetupStatus.mockResolvedValue(blockedSetupStatus); + + const response = await graphRoutes.request('/api/graph/full?limit=100&edgeFormat=indexed-v1'); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + nodes: [], + edgeFormat: 'indexed-v1', + edgeTypes: [], + edges: [], + totalNodes: 0, + totalEdges: 0, + windowOrder: 'degree-desc,id-asc', + degreeScope: 'global', + offset: 0, + limit: 100, + returned: 0, + hasMore: false, + nextOffset: null, + truncated: false, + storage: blockedSetupStatus.storage, + }); + }); }); describe('GET /api/graph/files', () => { @@ -351,7 +377,7 @@ describe('GET /api/graph/files', () => { expect(response.status).toBe(200); expect(await response.json()).toEqual(fileGraphResult); - expect(getFileGraph).toHaveBeenCalledWith(50, '/x', 0); + expect(getFileGraph).toHaveBeenCalledWith(50, '/x', 0, true); }); it.each(['NaN', 'Infinity', '-1', '1.5'])( @@ -367,7 +393,39 @@ describe('GET /api/graph/files', () => { it('forwards a positive file graph offset', async () => { await graphRoutes.request('/api/graph/files?limit=50&offset=3000'); - expect(getFileGraph).toHaveBeenCalledWith(50, undefined, 3000); + expect(getFileGraph).toHaveBeenCalledWith(50, undefined, 3000, true); + }); + + it.each([ + ['true', true], + ['false', false], + ])('forwards includeExternals=%s as %s', async (raw, expected) => { + const response = await graphRoutes.request(`/api/graph/files?includeExternals=${raw}`); + + expect(response.status).toBe(200); + expect(getFileGraph).toHaveBeenCalledWith(100, undefined, 0, expected); + }); + + it('rejects repeated includeExternals values', async () => { + const result = await errorFor( + '/api/graph/files?includeExternals=true&includeExternals=true', + ); + + expect(result).toEqual({ + status: 400, + error: 'includeExternals must be supplied once', + }); + expect(getFileGraph).not.toHaveBeenCalled(); + }); + + it.each(['TRUE', 'False', '1', '', 'yes'])('rejects includeExternals=%j', async (value) => { + const result = await errorFor(`/api/graph/files?includeExternals=${encodeURIComponent(value)}`); + + expect(result).toEqual({ + status: 400, + error: 'includeExternals must be true or false', + }); + expect(getFileGraph).not.toHaveBeenCalled(); }); it('does not return the global file graph for an unknown projectId', async () => { @@ -475,6 +533,174 @@ describe('POST /api/graph/induced-edges', () => { }); }); +describe('indexed-v1 edge transport', () => { + const fullGraphResult = { + nodes: [ + { id: 'node-a', label: 'Function', displayName: 'a', degree: 2 }, + { id: 'node-b', label: 'Function', displayName: 'b', degree: 2 }, + { id: 'node-c', label: 'Function', displayName: 'c', degree: 1 }, + ], + edges: [ + { source: 'node-a', target: 'node-b', label: 'CALLS' }, + { source: 'node-b', target: 'node-c', label: 'USES_TYPE' }, + { source: 'node-a', target: 'node-c', label: 'CALLS' }, + ], + totalNodes: 3, + totalEdges: 3, + windowOrder: 'degree-desc,id-asc' as const, + degreeScope: 'global' as const, + offset: 0, + limit: 100, + returned: 3, + hasMore: false, + nextOffset: null, + truncated: false, + }; + const getFileGraph = vi.fn(); + const getInducedEdges = vi.fn(); + const getNodeNeighbors = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockedFullGraph.mockResolvedValue(fullGraphResult as never); + mockedGetGraphClient.mockResolvedValue({} as never); + getFileGraph.mockResolvedValue({ + ...fullGraphResult, + nodes: fullGraphResult.nodes.map((node) => ({ + id: node.id, + displayName: node.displayName, + filePath: `/${node.id}.ts`, + symbolCount: 1, + label: 'File', + })), + }); + getInducedEdges.mockResolvedValue(fullGraphResult.edges); + getNodeNeighbors.mockResolvedValue({ + centerId: 'node-a', + nodes: fullGraphResult.nodes, + edges: fullGraphResult.edges, + incomingTruncated: false, + outgoingTruncated: false, + limit: 100, + }); + mockedCreateQueries.mockReturnValue({ getFileGraph, getInducedEdges, getNodeNeighbors } as never); + }); + + it('compacts full graph edges against response nodes and first-seen edge types', async () => { + const legacy = await graphRoutes.request('/api/graph/full'); + const compact = await graphRoutes.request('/api/graph/full?edgeFormat=indexed-v1'); + const legacyBody = await legacy.json(); + const compactBody = await compact.json(); + + expect(compactBody).toEqual({ + ...fullGraphResult, + edgeFormat: 'indexed-v1', + edgeTypes: ['CALLS', 'USES_TYPE'], + edges: [[0, 1, 0], [1, 2, 1], [0, 2, 0]], + }); + expect(JSON.stringify(compactBody).length).toBeLessThan(JSON.stringify(legacyBody).length); + }); + + it('compacts file graph edges without changing file metadata', async () => { + const response = await graphRoutes.request('/api/graph/files?edgeFormat=indexed-v1'); + const body = await response.json(); + + expect(body).toMatchObject({ + edgeFormat: 'indexed-v1', + edgeTypes: ['CALLS', 'USES_TYPE'], + edges: [[0, 1, 0], [1, 2, 1], [0, 2, 0]], + totalNodes: 3, + windowOrder: 'degree-desc,id-asc', + }); + expect(getFileGraph).toHaveBeenCalledWith(100, undefined, 0, true); + }); + + it('compacts neighbor edges against the neighbor node table', async () => { + const response = await graphRoutes.request( + '/api/graph/neighbors?id=node-a&edgeFormat=indexed-v1', + ); + + expect(await response.json()).toEqual({ + centerId: 'node-a', + nodes: fullGraphResult.nodes, + edgeFormat: 'indexed-v1', + edgeTypes: ['CALLS', 'USES_TYPE'], + edges: [[0, 1, 0], [1, 2, 1], [0, 2, 0]], + incomingTruncated: false, + outgoingTruncated: false, + limit: 100, + }); + }); + + it('compacts induced edges using only first-seen returned endpoint ids', async () => { + const response = await graphRoutes.request('/api/graph/induced-edges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ids: ['node-a', 'node-b', 'node-c', 'unknown'], + edgeFormat: 'indexed-v1', + }), + }); + + expect(await response.json()).toEqual({ + edgeFormat: 'indexed-v1', + nodeIds: ['node-a', 'node-b', 'node-c'], + edgeTypes: ['CALLS', 'USES_TYPE'], + edges: [[0, 1, 0], [1, 2, 1], [0, 2, 0]], + }); + }); + + it.each([ + '/api/graph/full?edgeFormat=other', + '/api/graph/files?edgeFormat=other', + '/api/graph/neighbors?id=node-a&edgeFormat=other', + ])('rejects an unsupported edge format on %s', async (path) => { + const result = await errorFor(path); + + expect(result).toEqual({ status: 400, error: 'edgeFormat must be indexed-v1' }); + }); + + it.each([ + [ + 'full graph', + '/api/graph/full?edgeFormat=indexed-v1', + '/api/graph/full?edgeFormat=indexed-v1&edgeFormat=indexed-v1', + ], + [ + 'file graph', + '/api/graph/files?edgeFormat=indexed-v1', + '/api/graph/files?edgeFormat=indexed-v1&edgeFormat=indexed-v1', + ], + [ + 'neighbors', + '/api/graph/neighbors?id=node-a&edgeFormat=indexed-v1', + '/api/graph/neighbors?id=node-a&edgeFormat=indexed-v1&edgeFormat=indexed-v1', + ], + ])('accepts one edgeFormat and rejects repeated values for %s', async ( + _name, + singlePath, + repeatedPath, + ) => { + const single = await graphRoutes.request(singlePath); + const repeated = await errorFor(repeatedPath); + + expect(single.status).toBe(200); + expect(repeated).toEqual({ status: 400, error: 'edgeFormat must be supplied once' }); + }); + + it('rejects an unsupported induced edge format before querying', async () => { + const response = await graphRoutes.request('/api/graph/induced-edges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: ['node-a'], edgeFormat: 'other' }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'edgeFormat must be indexed-v1' }); + expect(getInducedEdges).not.toHaveBeenCalled(); + }); +}); + describe('GET /api/graph/neighbors', () => { const getNodeNeighbors = vi.fn(); const neighborResult = { diff --git a/packages/api/src/__tests__/startup.test.ts b/packages/api/src/__tests__/startup.test.ts index 19c342f..f942e61 100644 --- a/packages/api/src/__tests__/startup.test.ts +++ b/packages/api/src/__tests__/startup.test.ts @@ -11,6 +11,28 @@ const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '../.. const workspaceDirectory = resolve(packageDirectory, '../..'); const temporaryDirectories: string[] = []; +async function reserveUnusedPort(): Promise { + const reservation = createServer(); + await new Promise((resolvePromise, reject) => { + reservation.once('error', reject); + reservation.listen({ port: 0, host: API_BIND_HOST }, resolvePromise); + }); + + const address = reservation.address(); + if (address === null || typeof address === 'string') { + reservation.close(); + throw new Error('Could not reserve an unused local port'); + } + + await new Promise((resolvePromise, reject) => { + reservation.close((error) => { + if (error) reject(error); + else resolvePromise(); + }); + }); + return address.port; +} + afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => @@ -20,6 +42,87 @@ afterEach(async () => { }); describe('API server startup', () => { + it('stays available after an unreachable graph backend rejects the post-listen warmup', async () => { + const apiPort = await reserveUnusedPort(); + const unreachableGraphPort = await reserveUnusedPort(); + const stateDirectory = await mkdtemp(join(tmpdir(), 'codegraph-api-warmup-')); + temporaryDirectories.push(stateDirectory); + const child = spawn( + process.execPath, + ['--import', 'tsx', 'packages/api/src/index.ts'], + { + cwd: workspaceDirectory, + env: { + ...process.env, + API_PORT: String(apiPort), + CODEGRAPH_DATA_DIR: stateDirectory, + CODEGRAPH_DB_PATH: join(stateDirectory, 'db'), + CODEGRAPH_DRIVER: 'falkordb', + FALKORDB_URL: `${API_BIND_HOST}:${unreachableGraphPort}`, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stdout = ''; + let stderr = ''; + const listeningLine = `CodeGraph API server running on http://localhost:${apiPort}`; + const warmupWarning = + '[codegraph] Graph warmup did not complete; graph requests will retry through storage state.\n'; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + + try { + await new Promise((resolvePromise, reject) => { + // CI Linux runners boot the tsx child roughly 10x slower than a local + // machine (the sibling occupied-port test measures ~5.4s there), so the + // ceiling asserts eventual behavior, not latency. + const timeout = setTimeout(() => { + reject(new Error('API server did not emit the graph warmup warning after listening')); + }, 30_000); + const inspectOutput = (): void => { + if (stdout.includes(listeningLine) && stderr.includes(warmupWarning)) { + clearTimeout(timeout); + resolvePromise(); + } + }; + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + inspectOutput(); + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + inspectOutput(); + }); + child.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + reject(new Error(`API server exited during graph warmup: code=${String(code)} signal=${String(signal)}`)); + }); + }); + + const healthResponse = await fetch(`http://${API_BIND_HOST}:${apiPort}/health`); + expect(healthResponse.status).toBe(200); + expect(child.exitCode).toBeNull(); + expect(child.signalCode).toBeNull(); + expect(stdout).toContain(listeningLine); + expect(stderr).toBe(warmupWarning); + expect(stderr).not.toContain('UnhandledPromiseRejection'); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + await new Promise((resolvePromise) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolvePromise(); + return; + } + child.once('exit', () => resolvePromise()); + }); + } + }, 45_000); + it('reports an occupied API bind address without an unhandled stack trace', async () => { const blockingServer = createServer(); await new Promise((resolvePromise, reject) => { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 7cbf2d5..ecd8b29 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -6,6 +6,7 @@ */ import { createAdaptorServer } from '@hono/node-server'; +import { getGraphClient } from '@codegraph/core'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { readFile } from 'node:fs/promises'; @@ -115,6 +116,11 @@ server.listen(port, API_BIND_HOST, () => { server.close(); return; } + void getGraphClient() + .then((client) => client.roQuery('RETURN 1 AS warmup')) + .catch(() => { + console.warn('[codegraph] Graph warmup did not complete; graph requests will retry through storage state.'); + }); console.log(`CodeGraph API server running on http://localhost:${info.port}`); console.log(` Config: ${loadedEnvFile ?? 'process environment only (no .env found)'}`); console.log( diff --git a/packages/api/src/routes/graph.ts b/packages/api/src/routes/graph.ts index 76460ee..f653182 100644 --- a/packages/api/src/routes/graph.ts +++ b/packages/api/src/routes/graph.ts @@ -19,6 +19,14 @@ type BoundedIntegerResult = | { valid: true; value?: number } | { valid: false; error: string }; +type IndexedEdgeFormat = 'indexed-v1'; +type CompactEdgeTuple = [sourceNodeIndex: number, targetNodeIndex: number, edgeTypeIndex: number]; +type EdgeTransport = { source: string; target: string; label: string }; + +type EdgeFormatResult = + | { valid: true; value?: IndexedEdgeFormat } + | { valid: false; error: string }; + function boundedPositiveInteger( raw: string | undefined, name: string, @@ -41,6 +49,22 @@ function nonNegativeInteger(raw: string | undefined, name: string): BoundedInteg return { valid: true, value }; } +function parseEdgeFormat(raw: unknown): EdgeFormatResult { + if (raw === undefined) return { valid: true }; + if (raw === 'indexed-v1') return { valid: true, value: raw }; + return { valid: false, error: 'edgeFormat must be indexed-v1' }; +} + +function parseIncludeExternals(raw: string | undefined): boolean | null { + if (raw === undefined || raw === 'true') return true; + if (raw === 'false') return false; + return null; +} + +function hasRepeatedQueryValue(values: readonly string[] | undefined): boolean { + return values !== undefined && values.length > 1; +} + async function resolveProjectRootPath(projectId: string): Promise { const client = await getGraphClient(); const projectResult = await client.roQuery<{ rootPath: string | null }>( @@ -51,9 +75,9 @@ async function resolveProjectRootPath(projectId: string): Promise } function projectFullGraphResponse; + edges: EdgeTransport[]; }>(data: T): Omit & { - edges: Array<{ source: string; target: string; label: string }>; + edges: EdgeTransport[]; } { return { ...data, @@ -61,11 +85,88 @@ function projectFullGraphResponse, value: string): number { + const index = indexes.get(value); + if (index === undefined) throw new Error(`Compact edge endpoint is absent from its node table: ${value}`); + return index; +} + +function compactEdges( + edges: readonly EdgeTransport[], + nodeIds: readonly string[], +): { edgeTypes: string[]; edges: CompactEdgeTuple[] } { + const nodeIndexes = new Map(nodeIds.map((id, index) => [id, index])); + const edgeTypes: string[] = []; + const edgeTypeIndexes = new Map(); + const compact = edges.map((edge): CompactEdgeTuple => { + let edgeTypeIndex = edgeTypeIndexes.get(edge.label); + if (edgeTypeIndex === undefined) { + edgeTypeIndex = edgeTypes.length; + edgeTypes.push(edge.label); + edgeTypeIndexes.set(edge.label, edgeTypeIndex); + } + return [ + requiredTableIndex(nodeIndexes, edge.source), + requiredTableIndex(nodeIndexes, edge.target), + edgeTypeIndex, + ]; + }); + return { edgeTypes, edges: compact }; +} + +function projectIndexedNodeEdges; + edges: EdgeTransport[]; +}>(data: T): Omit & { + edgeFormat: IndexedEdgeFormat; + edgeTypes: string[]; + edges: CompactEdgeTuple[]; +} { + const compact = compactEdges(data.edges, data.nodes.map((node) => node.id)); + return { + ...data, + edgeFormat: 'indexed-v1', + edgeTypes: compact.edgeTypes, + edges: compact.edges, + }; +} + +function projectIndexedInducedEdges(edges: EdgeTransport[]): { + edgeFormat: IndexedEdgeFormat; + nodeIds: string[]; + edgeTypes: string[]; + edges: CompactEdgeTuple[]; +} { + const nodeIds: string[] = []; + const seenNodeIds = new Set(); + for (const edge of edges) { + for (const nodeId of [edge.source, edge.target]) { + if (seenNodeIds.has(nodeId)) continue; + seenNodeIds.add(nodeId); + nodeIds.push(nodeId); + } + } + const compact = compactEdges(edges, nodeIds); + return { + edgeFormat: 'indexed-v1', + nodeIds, + edgeTypes: compact.edgeTypes, + edges: compact.edges, + }; +} + /** GET /api/graph/full?limit=N&offset=N&projectId=X returns a degree-ordered page with scoped totals. */ graphRoutes.get('/api/graph/full', async (c) => { let limit = 100; let offset = 0; + let edgeFormat: IndexedEdgeFormat | undefined; try { + if (hasRepeatedQueryValue(c.req.queries('edgeFormat'))) { + return c.json({ error: 'edgeFormat must be supplied once' }, 400); + } + const parsedEdgeFormat = parseEdgeFormat(c.req.query('edgeFormat')); + if (!parsedEdgeFormat.valid) return c.json({ error: parsedEdgeFormat.error }, 400); + edgeFormat = parsedEdgeFormat.value; const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', FULL_GRAPH_LIMIT_MAX); if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400); const parsedOffset = nonNegativeInteger(c.req.query('offset'), 'offset'); @@ -78,18 +179,21 @@ graphRoutes.get('/api/graph/full', async (c) => { const rootPath = await resolveProjectRootPath(projectId); if (!rootPath) return c.json({ error: 'Project not found' }, 404); const data = await codeGraphService.getFullGraph(limit, rootPath, offset); - return c.json(projectFullGraphResponse(data)); + const projected = projectFullGraphResponse(data); + return c.json(edgeFormat ? projectIndexedNodeEdges(projected) : projected); } // No project filter — return all const rootPath = c.req.query('rootPath') ?? undefined; const data = await codeGraphService.getFullGraph(limit, rootPath, offset); - return c.json(projectFullGraphResponse(data)); + const projected = projectFullGraphResponse(data); + return c.json(edgeFormat ? projectIndexedNodeEdges(projected) : projected); } catch (error) { const setup = await readBlockedSetupStatus(); if (setup !== null) { return c.json({ nodes: [], + ...(edgeFormat ? { edgeFormat, edgeTypes: [] } : {}), edges: [], totalNodes: 0, totalEdges: 0, @@ -111,6 +215,18 @@ graphRoutes.get('/api/graph/full', async (c) => { /** GET /api/graph/files?projectId=X&limit=N&offset=N - bounded File-to-File IMPORTS graph. */ graphRoutes.get('/api/graph/files', async (c) => { try { + if (hasRepeatedQueryValue(c.req.queries('edgeFormat'))) { + return c.json({ error: 'edgeFormat must be supplied once' }, 400); + } + if (hasRepeatedQueryValue(c.req.queries('includeExternals'))) { + return c.json({ error: 'includeExternals must be supplied once' }, 400); + } + const parsedEdgeFormat = parseEdgeFormat(c.req.query('edgeFormat')); + if (!parsedEdgeFormat.valid) return c.json({ error: parsedEdgeFormat.error }, 400); + const includeExternals = parseIncludeExternals(c.req.query('includeExternals')); + if (includeExternals === null) { + return c.json({ error: 'includeExternals must be true or false' }, 400); + } const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', FILE_GRAPH_LIMIT_MAX); if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400); const parsedOffset = nonNegativeInteger(c.req.query('offset'), 'offset'); @@ -127,8 +243,8 @@ graphRoutes.get('/api/graph/files', async (c) => { } const client = await getGraphClient(); - const data = await createQueries(client).getFileGraph(limit, rootPath, offset); - return c.json(data); + const data = await createQueries(client).getFileGraph(limit, rootPath, offset, includeExternals); + return c.json(parsedEdgeFormat.value ? projectIndexedNodeEdges(data) : data); } catch (error) { return c.json({ error: safeErrorMessage('GET /api/graph/files', error, 'Failed to fetch file graph.'), @@ -152,6 +268,8 @@ graphRoutes.post('/api/graph/induced-edges', async (c) => { if (body.ids.length > INDUCED_EDGE_IDS_MAX) { return c.json({ error: `ids must contain at most ${INDUCED_EDGE_IDS_MAX} items` }, 400); } + const parsedEdgeFormat = parseEdgeFormat('edgeFormat' in body ? body.edgeFormat : undefined); + if (!parsedEdgeFormat.valid) return c.json({ error: parsedEdgeFormat.error }, 400); const projectId = c.req.query('projectId'); let rootPath: string | undefined; @@ -163,9 +281,10 @@ graphRoutes.post('/api/graph/induced-edges', async (c) => { const client = await getGraphClient(); const edges = await createQueries(client).getInducedEdges(body.ids, rootPath); - return c.json({ - edges: edges.map(({ source, target, label }) => ({ source, target, label })), - }); + const projected = edges.map(({ source, target, label }) => ({ source, target, label })); + return c.json(parsedEdgeFormat.value + ? projectIndexedInducedEdges(projected) + : { edges: projected }); } catch (error) { return c.json({ error: safeErrorMessage( @@ -180,6 +299,11 @@ graphRoutes.post('/api/graph/induced-edges', async (c) => { /** GET /api/graph/neighbors?id=X&limit=N - direct neighbors and their induced graph. */ graphRoutes.get('/api/graph/neighbors', async (c) => { try { + if (hasRepeatedQueryValue(c.req.queries('edgeFormat'))) { + return c.json({ error: 'edgeFormat must be supplied once' }, 400); + } + const parsedEdgeFormat = parseEdgeFormat(c.req.query('edgeFormat')); + if (!parsedEdgeFormat.valid) return c.json({ error: parsedEdgeFormat.error }, 400); const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', NEIGHBOR_LIMIT_MAX); if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400); @@ -189,7 +313,7 @@ graphRoutes.get('/api/graph/neighbors', async (c) => { const client = await getGraphClient(); const data = await createQueries(client).getNodeNeighbors(id, parsedLimit.value ?? 100); if (!data) return c.json({ error: 'Graph node not found' }, 404); - return c.json(data); + return c.json(parsedEdgeFormat.value ? projectIndexedNodeEdges(data) : data); } catch (error) { return c.json({ error: safeErrorMessage('GET /api/graph/neighbors', error, 'Failed to fetch node neighbors.'), diff --git a/packages/dashboard/src/components/dashboard/app-shell.tsx b/packages/dashboard/src/components/dashboard/app-shell.tsx index 92449b0..75ef6c2 100644 --- a/packages/dashboard/src/components/dashboard/app-shell.tsx +++ b/packages/dashboard/src/components/dashboard/app-shell.tsx @@ -17,7 +17,9 @@ import { appendGraphExpansion, DEFAULT_GRAPH_VIEW, fetchGraphNodeDetail, + persistGraphExternalsState, persistGraphViewState, + readGraphExternalsState, readGraphViewState, resetGraphView, type GraphCanvasViewState, @@ -230,6 +232,15 @@ export function AppShell({ return { ...DEFAULT_GRAPH_VIEW, fileScope: null, expansions: [] } } }) + const [includeExternals, setIncludeExternals] = useState(() => { + if (typeof window === 'undefined') return true + try { + return readGraphExternalsState(window.location, window.localStorage) + } catch (error) { + console.warn('Unable to read the saved graph externals preference', error) + return true + } + }) const [expansionRequest, setExpansionRequest] = useState<{ node: GraphNode; sequence: number } | null>(null) const [selectionHistory, setSelectionHistory] = useState(EMPTY_SELECTION_HISTORY) const canvasViewRef = useRef(canvasView) @@ -255,6 +266,19 @@ export function AppShell({ } }, [canvasView]) + useEffect(() => { + try { + persistGraphExternalsState( + includeExternals, + window.location, + window.history, + window.localStorage, + ) + } catch (error) { + console.warn('Unable to persist the graph externals preference', error) + } + }, [includeExternals]) + const handleNodeSelect = useCallback((node: GraphNode | null) => { setSelectionHistory((history) => pushSelectionHistory(history, node, canvasViewRef.current)) }, []) @@ -311,6 +335,11 @@ export function AppShell({ recordCanvasView({ ...canvasView, mode, offset: 0, fileScope: null, expansions: [] }) }, [canvasView, recordCanvasView]) + const handleIncludeExternalsChange = useCallback((value: boolean) => { + setIncludeExternals(value) + recordCanvasView({ ...canvasView, offset: 0, fileScope: null, expansions: [] }) + }, [canvasView, recordCanvasView]) + const handleWindowLimitChange = useCallback((limit: GraphWindowLimit) => { recordCanvasView({ ...canvasView, limit, offset: 0, expansions: [] }) }, [canvasView, recordCanvasView]) @@ -474,11 +503,13 @@ export function AppShell({ projectId={projectId} referenceNodeIds={referenceNodeIds} mode={canvasView.mode} + includeExternals={includeExternals} windowLimit={canvasView.limit} pageOffset={canvasView.offset} fileScope={canvasView.fileScope} restoredExpansions={canvasView.expansions} onModeChange={handleModeChange} + onIncludeExternalsChange={handleIncludeExternalsChange} onWindowLimitChange={handleWindowLimitChange} onPageChange={handlePageChange} expansionRequest={expansionRequest} diff --git a/packages/dashboard/src/components/dashboard/files-externals-toggle.test.tsx b/packages/dashboard/src/components/dashboard/files-externals-toggle.test.tsx new file mode 100644 index 0000000..62735ec --- /dev/null +++ b/packages/dashboard/src/components/dashboard/files-externals-toggle.test.tsx @@ -0,0 +1,253 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type cytoscape from 'cytoscape' +import type { GraphWindow } from '@/lib/graph-window' + +const graphWindowMocks = vi.hoisted(() => ({ + fetchGraphWindow: vi.fn(), + fetchGraphInducedEdges: vi.fn(), + readGraphExternalsState: vi.fn(), + persistGraphExternalsState: vi.fn(), +})) + +vi.mock('@/lib/graph-window', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetchGraphWindow: graphWindowMocks.fetchGraphWindow, + fetchGraphInducedEdges: graphWindowMocks.fetchGraphInducedEdges, + readGraphExternalsState: ( + ...args: Parameters + ): boolean => { + graphWindowMocks.readGraphExternalsState(...args) + return actual.readGraphExternalsState(...args) + }, + persistGraphExternalsState: ( + ...args: Parameters + ): void => { + graphWindowMocks.persistGraphExternalsState(...args) + actual.persistGraphExternalsState(...args) + }, + } +}) + +vi.mock('cytoscape', async (importOriginal) => { + const actual = await importOriginal() + const defaultExport: unknown = Reflect.get(actual, 'default') + const factory = typeof defaultExport === 'function' + ? defaultExport as typeof actual + : actual + return { + ...actual, + default: (options: cytoscape.CytoscapeOptions): cytoscape.Core => factory({ + ...options, + container: undefined, + headless: true, + style: [], + }), + } +}) + +import { AppShell } from './app-shell' +import { GraphCanvas } from './graph-canvas' +import { GraphControls } from './graph-controls' + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true + +interface GraphWindowRequest { + offset?: number + includeExternals?: boolean +} + +function graphWindowAt(offset: number): GraphWindow { + return { + nodes: [], + edges: [], + totalNodes: 600, + totalEdges: 0, + windowOrder: 'degree-desc,id-asc', + truncation: { incoming: false, outgoing: false }, + offset, + limit: 300, + returned: 0, + hasMore: offset < 300, + nextOffset: offset < 300 ? 300 : null, + } +} + +async function render(element: React.ReactNode): Promise<{ + container: HTMLDivElement + root: Root +}> { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + await act(async () => root.render(element)) + return { container, root } +} + +beforeEach(() => { + window.localStorage.clear() + window.history.replaceState(null, '', '/') + graphWindowMocks.fetchGraphWindow.mockReset() + graphWindowMocks.fetchGraphWindow.mockImplementation( + async (request: GraphWindowRequest) => graphWindowAt(request.offset ?? 0), + ) + graphWindowMocks.fetchGraphInducedEdges.mockReset() + graphWindowMocks.fetchGraphInducedEdges.mockResolvedValue([]) + graphWindowMocks.readGraphExternalsState.mockClear() + graphWindowMocks.persistGraphExternalsState.mockClear() +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('Files externals control', () => { + it('renders only for Files with its pressed state and inverts the value on activation', async () => { + const onIncludeExternalsChange = vi.fn() + const filesHtml = renderToStaticMarkup( + , + ) + const symbolsHtml = renderToStaticMarkup( + , + ) + + expect(filesHtml).toMatch(/]*aria-pressed="false"[^>]*aria-label="Show unresolved external modules"/) + expect(filesHtml).toContain('focus-visible:ring') + expect(symbolsHtml).not.toContain('Show unresolved external modules') + + const view = await render( + , + ) + const button = view.container.querySelector( + '[aria-label="Show unresolved external modules"]', + ) + expect(button).toBeInstanceOf(HTMLButtonElement) + await act(async () => button?.click()) + expect(onIncludeExternalsChange).toHaveBeenCalledWith(true) + await act(async () => view.root.unmount()) + }) + + it('restores URL preference over storage, persists changes, and refetches from offset zero', async () => { + window.localStorage.setItem('codegraph.graphIncludeExternals', 'true') + window.history.replaceState( + null, + '', + '/?graphMode=files&graphOffset=300&graphExternals=false', + ) + + const view = await render() + await act(async () => { + await vi.waitFor(() => expect(graphWindowMocks.fetchGraphWindow).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'files', + offset: 300, + includeExternals: false, + }), + )) + }) + expect(graphWindowMocks.readGraphExternalsState).toHaveBeenCalled() + expect(view.container.querySelector( + '[aria-label="Show unresolved external modules"]', + )?.getAttribute('aria-pressed')).toBe('false') + + await act(async () => { + view.container.querySelector( + '[aria-label="Show unresolved external modules"]', + )?.click() + }) + + await act(async () => { + await vi.waitFor(() => expect(graphWindowMocks.fetchGraphWindow).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'files', + offset: 0, + includeExternals: true, + }), + )) + }) + expect(graphWindowMocks.persistGraphExternalsState).toHaveBeenLastCalledWith( + true, + window.location, + window.history, + window.localStorage, + ) + expect(new URL(window.location.href).searchParams.get('graphExternals')).toBe('true') + expect(window.localStorage.getItem('codegraph.graphIncludeExternals')).toBe('true') + await act(async () => view.root.unmount()) + }) + + it('passes includeExternals on initial and Load More graph requests', async () => { + const view = await render( + , + ) + + await act(async () => { + await vi.waitFor(() => expect(graphWindowMocks.fetchGraphWindow).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'files', + offset: 0, + includeExternals: false, + }), + )) + }) + const loadMore = view.container.querySelector('[aria-label^="Load next"]') + expect(loadMore?.disabled).toBe(false) + await act(async () => loadMore?.click()) + await act(async () => { + await vi.waitFor(() => expect(graphWindowMocks.fetchGraphWindow).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'files', + offset: 300, + includeExternals: false, + }), + )) + }) + await act(async () => view.root.unmount()) + }) +}) diff --git a/packages/dashboard/src/components/dashboard/graph-canvas.tsx b/packages/dashboard/src/components/dashboard/graph-canvas.tsx index e89c5f8..791e164 100644 --- a/packages/dashboard/src/components/dashboard/graph-canvas.tsx +++ b/packages/dashboard/src/components/dashboard/graph-canvas.tsx @@ -111,10 +111,12 @@ interface GraphCanvasProps { hiddenNodeTypes: Set projectId?: string | null mode?: GraphViewMode + includeExternals?: boolean windowLimit?: GraphWindowLimit pageOffset?: number fileScope?: GraphNode | null onModeChange?: (mode: GraphViewMode) => void + onIncludeExternalsChange?: (value: boolean) => void onWindowLimitChange?: (limit: GraphWindowLimit) => void onPageChange?: (offset: number) => void expansionRequest?: { node: GraphNode; sequence: number } | null @@ -133,10 +135,12 @@ export function GraphCanvas({ hiddenNodeTypes, projectId, mode = 'symbols', + includeExternals = true, windowLimit = 300, pageOffset = 0, fileScope = null, onModeChange, + onIncludeExternalsChange, onWindowLimitChange, onPageChange, expansionRequest = null, @@ -233,6 +237,7 @@ export function GraphCanvas({ mode, limit: windowLimit, offset: pageOffset, + includeExternals, projectId, fileScope, }) @@ -362,7 +367,7 @@ export function GraphCanvas({ graphWindowRef.current = null baseWindowRef.current = null } - }, [apiUrl, fileScope, mode, onNodeSelect, pageOffset, projectId, windowLimit]) + }, [apiUrl, fileScope, includeExternals, mode, onNodeSelect, pageOffset, projectId, windowLimit]) useEffect(() => { if ( @@ -606,6 +611,7 @@ export function GraphCanvas({ mode, limit: windowLimit, offset: current.nextOffset, + includeExternals, projectId, signal: controller.signal, }) @@ -649,7 +655,7 @@ export function GraphCanvas({ setLoadingMore(false) } } - }, [apiUrl, fileScope, mode, projectId, windowLimit]) + }, [apiUrl, fileScope, includeExternals, mode, projectId, windowLimit]) const handleRelayout = useCallback((newLayout?: LayoutName) => { const l = newLayout ?? layout @@ -755,6 +761,8 @@ export function GraphCanvas({ onWindowLimitChange={onWindowLimitChange} mode={mode} onModeChange={onModeChange} + includeExternals={includeExternals} + onIncludeExternalsChange={onIncludeExternalsChange} canReset={graphWindow !== null && baseWindow !== null && (graphWindow !== baseWindow || pageOffset > 0)} pageOffset={graphWindow?.offset ?? pageOffset} pageReturned={graphWindow?.returned ?? nodeCount} diff --git a/packages/dashboard/src/components/dashboard/graph-controls.tsx b/packages/dashboard/src/components/dashboard/graph-controls.tsx index 232983f..adfd46f 100644 --- a/packages/dashboard/src/components/dashboard/graph-controls.tsx +++ b/packages/dashboard/src/components/dashboard/graph-controls.tsx @@ -16,6 +16,8 @@ interface GraphControlsProps { onWindowLimitChange?: (limit: GraphWindowLimit) => void mode?: GraphViewMode onModeChange?: (mode: GraphViewMode) => void + includeExternals?: boolean + onIncludeExternalsChange?: (value: boolean) => void canReset?: boolean pageOffset?: number pageReturned?: number @@ -51,6 +53,8 @@ export function GraphControls({ onWindowLimitChange, mode = 'symbols', onModeChange, + includeExternals = true, + onIncludeExternalsChange, canReset = false, pageOffset = 0, pageReturned = nodeCount, @@ -92,6 +96,18 @@ export function GraphControls({ ))} + {mode === 'files' && ( + + )}