diff --git a/packages/mcp-server/src/services/graph-service.ts b/packages/mcp-server/src/services/graph-service.ts index 8cb08116..c9e74071 100644 --- a/packages/mcp-server/src/services/graph-service.ts +++ b/packages/mcp-server/src/services/graph-service.ts @@ -3291,18 +3291,14 @@ export class GraphService { OPTIONAL MATCH (g)<-[:BELONGS_TO]-(w:WorkItem) OPTIONAL MATCH (w)-[e:DEPENDS_ON|BLOCKS|RELATES_TO|CONTAINS|PART_OF]-(:WorkItem) WITH g, collect(DISTINCT w) as items, count(DISTINCT e) as edgeCount - CALL { - WITH items - UNWIND items as i - RETURN i.type as type, count(i) as cnt - } - WITH g, items, edgeCount, collect({type: type, count: cnt}) as typeCounts - CALL { - WITH items - UNWIND items as i - RETURN i.status as status, count(i) as cnt - } - WITH g, items, edgeCount, typeCounts, collect({status: status, count: cnt}) as statusCounts + // Return the raw type/status lists and tally them in JS. A + // CALL { UNWIND items ... } subquery returns ZERO rows for an empty + // graph (UNWIND of an empty list), which dropped the whole result and + // made get_graph_context wrongly report a brand-new empty graph as + // "not found". + WITH g, items, edgeCount, + [x IN items | x.type] as types, + [x IN items | x.status] as statuses CALL { WITH g OPTIONAL MATCH (g)<-[:BELONGS_TO]-(b:WorkItem)-[r:BLOCKS]->(:WorkItem) @@ -3318,7 +3314,7 @@ export class GraphService { ORDER BY rw.updatedAt DESC LIMIT 5 RETURN collect({id: rw.id, title: rw.title, status: rw.status, type: rw.type, updatedAt: rw.updatedAt}) as recent } - RETURN g, size(items) as nodeCount, edgeCount, typeCounts, statusCounts, blockers, recent + RETURN g, size(items) as nodeCount, edgeCount, types, statuses, blockers, recent `; const result = await session.run(query, { graphId: args.graphId }); @@ -3334,8 +3330,8 @@ export class GraphService { const record = result.records[0]; const g = record.get('g').properties; - const typeCounts = (record.get('typeCounts') || []) as Array<{ type: string; count: unknown }>; - const statusCounts = (record.get('statusCounts') || []) as Array<{ status: string; count: unknown }>; + const types = (record.get('types') || []) as Array; + const statuses = (record.get('statuses') || []) as Array; const blockers = (record.get('blockers') || []) as Array<{ id: string; title: string; blocksCount: unknown }>; const recent = (record.get('recent') || []) as Array<{ id: string; @@ -3346,12 +3342,12 @@ export class GraphService { }>; const byType: Record = {}; - for (const t of typeCounts) { - if (t.type) byType[t.type] = toNum(t.count); + for (const t of types) { + if (t) byType[t] = (byType[t] ?? 0) + 1; } const byStatus: Record = {}; - for (const s of statusCounts) { - if (s.status) byStatus[s.status] = toNum(s.count); + for (const s of statuses) { + if (s) byStatus[s] = (byStatus[s] ?? 0) + 1; } return { diff --git a/packages/mcp-server/tests/mock-neo4j.ts b/packages/mcp-server/tests/mock-neo4j.ts index df11cf97..5579a1af 100644 --- a/packages/mcp-server/tests/mock-neo4j.ts +++ b/packages/mcp-server/tests/mock-neo4j.ts @@ -226,7 +226,7 @@ export function createMockDriver(): Driver { } // Handle compact graph context query (get_graph_context, AI-6) - if (query.includes('typeCounts') && query.includes('statusCounts')) { + if (query.includes('size(items) as nodeCount') && query.includes('as statuses')) { if (params?.graphId === 'missing-graph-id') { return { records: [] }; } @@ -241,14 +241,8 @@ export function createMockDriver(): Driver { }, nodeCount: { toNumber: () => 12 }, edgeCount: { toNumber: () => 7 }, - typeCounts: [ - { type: 'TASK', count: { toNumber: () => 8 } }, - { type: 'BUG', count: { toNumber: () => 4 } } - ], - statusCounts: [ - { status: 'IN_PROGRESS', count: { toNumber: () => 5 } }, - { status: 'BLOCKED', count: { toNumber: () => 2 } } - ], + types: ['TASK', 'TASK', 'TASK', 'TASK', 'TASK', 'TASK', 'TASK', 'TASK', 'BUG', 'BUG', 'BUG', 'BUG'], + statuses: ['IN_PROGRESS', 'IN_PROGRESS', 'IN_PROGRESS', 'IN_PROGRESS', 'IN_PROGRESS', 'BLOCKED', 'BLOCKED'], blockers: [ { id: 'node-1', title: 'Fix auth', blocksCount: { toNumber: () => 3 } } ], diff --git a/packages/mcp-server/tests/neo4j-contract.test.ts b/packages/mcp-server/tests/neo4j-contract.test.ts index 570d906a..f4db34a5 100644 --- a/packages/mcp-server/tests/neo4j-contract.test.ts +++ b/packages/mcp-server/tests/neo4j-contract.test.ts @@ -29,6 +29,7 @@ describe.skipIf(!RUN)('MCP GraphService — real Neo4j contract', () => { let driver: Driver; let svc: GraphService; const createdNodes: string[] = []; + const createdGraphs: string[] = []; beforeAll(async () => { driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASS)); @@ -42,6 +43,9 @@ describe.skipIf(!RUN)('MCP GraphService — real Neo4j contract', () => { if (createdNodes.length) { await session?.run('MATCH (n:WorkItem) WHERE n.id IN $ids DETACH DELETE n', { ids: createdNodes }); } + if (createdGraphs.length) { + await session?.run('MATCH (g:Graph) WHERE g.id IN $ids DETACH DELETE g', { ids: createdGraphs }); + } } catch { /* ignore */ } await session?.close(); await driver?.close(); @@ -81,6 +85,51 @@ describe.skipIf(!RUN)('MCP GraphService — real Neo4j contract', () => { expect(delEdge).toMatch(/delet|success|true|removed/); }); + it('getGraphContext on a brand-new EMPTY graph returns zero counts, not "not found"', async () => { + // Regression: the type/status tally used `CALL { UNWIND items ... }`, and + // UNWIND of an empty list yields ZERO rows, which dropped the whole result + // row — so an existing empty graph was reported as "not found". + const created = parse(await svc.createGraph({ name: `Contract Empty ${Date.now()}`, type: 'PROJECT' } as any)); + const graphId = created.graph.id; + expect(graphId, 'createGraph persists and returns an id').toBeTruthy(); + createdGraphs.push(graphId); + + const ctx = parse(await svc.getGraphContext({ graphId } as any)).context; + expect(ctx.graph.id, 'the empty graph is found by id').toBe(graphId); + expect(ctx.counts.nodes, 'empty graph has zero nodes').toBe(0); + expect(ctx.counts.edges, 'empty graph has zero edges').toBe(0); + expect(ctx.counts.byType, 'no type tallies on an empty graph').toEqual({}); + expect(ctx.counts.byStatus, 'no status tallies on an empty graph').toEqual({}); + expect(Array.isArray(ctx.topBlockers) && ctx.topBlockers.length, 'no blockers').toBe(0); + expect(Array.isArray(ctx.recentActivity) && ctx.recentActivity.length, 'no recent activity').toBe(0); + }); + + it('getGraphContext tallies type/status once a graph has items', async () => { + const g = parse(await svc.createGraph({ name: `Contract Populated ${Date.now()}`, type: 'PROJECT' } as any)); + const graphId = g.graph.id; + createdGraphs.push(graphId); + + // Attach two TASK/IN_PROGRESS items to this graph + const session = driver.session(); + try { + for (const i of [1, 2]) { + const id = parse(await svc.createNode({ title: `Pop ${i} ${Date.now()}`, type: 'TASK', status: 'IN_PROGRESS' } as any)).node.id; + createdNodes.push(id); + await session.run( + 'MATCH (w:WorkItem {id: $id}), (g:Graph {id: $gid}) MERGE (w)-[:BELONGS_TO]->(g)', + { id, gid: graphId } + ); + } + } finally { + await session.close(); + } + + const ctx = parse(await svc.getGraphContext({ graphId } as any)).context; + expect(ctx.counts.nodes, 'two items counted').toBe(2); + expect(ctx.counts.byType.TASK, 'both items tallied under TASK').toBe(2); + expect(ctx.counts.byStatus.IN_PROGRESS, 'both items tallied under IN_PROGRESS').toBe(2); + }); + it('browseGraph returns well-formed data over a real DB', async () => { const browsed = parse(await svc.browseGraph({ query_type: 'all_nodes', limit: 25 } as any)); const arr = browsed.nodes ?? browsed.results ?? browsed.workItems; diff --git a/tests/e2e/user-smoke.spec.ts b/tests/e2e/user-smoke.spec.ts index 87b410bd..798f7ec0 100644 --- a/tests/e2e/user-smoke.spec.ts +++ b/tests/e2e/user-smoke.spec.ts @@ -155,6 +155,63 @@ test.describe('user smoke: the app works from a user point of view @smoke', () = }, name); }); + // A brand-new EMPTY graph (the very first thing a user sees after "Create + // Graph") must render its empty-state invitation, NOT crash or show error + // chrome. UI counterpart to the get_graph_context "empty graph reported as + // not found" bug — the empty case is a first-class state. + test('a brand-new empty graph shows the empty-state, not an error @smoke', async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(e.message)); + + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(2000); + + const graphId = await page.evaluate(async () => { + const token = localStorage.getItem('authToken') ?? ''; + const post = (query: string, variables?: unknown) => + fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query, variables }), + }).then((r) => r.json()); + const me = await post('{ me { id } }'); + const userId = me.data.me.id; + const g = await post( + `mutation($input: [GraphCreateInput!]!) { createGraphs(input: $input) { graphs { id } } }`, + { input: [{ name: `Empty Smoke ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] } + ); + return g.data.createGraphs.graphs[0].id as string; + }); + expect(graphId, 'empty graph created').toBeTruthy(); + + try { + await page.evaluate((gid) => localStorage.setItem('currentGraphId', gid), graphId); + await page.reload(); + await page.waitForTimeout(6000); + + await expect( + page.locator('text=/Create Your First Work Item|Transform Your Vision/i').first(), + 'empty graph shows its create-first-item invitation' + ).toBeVisible({ timeout: 10000 }); + + const errorBadges = await page + .locator('.graph-container') + .locator('text=/^Error$|not found|failed to load|connection lost/i') + .count(); + expect(errorBadges, 'no error chrome on an empty graph').toBe(0); + expect(pageErrors, `uncaught page errors on empty graph: ${pageErrors[0] ?? ''}`).toEqual([]); + } finally { + await page.evaluate(async (gid) => { + const token = localStorage.getItem('authToken') ?? ''; + await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query: `mutation($id: ID!) { deleteGraphs(where: { id: $id }) { nodesDeleted } }`, variables: { id: gid } }), + }); + }, graphId); + } + }); + test('data integrity: no orphan edges in the database @smoke', async ({ page }) => { await login(page, TEST_USERS.ADMIN); const orphans = await page.evaluate(async () => {