diff --git a/.gitignore b/.gitignore
index 204b380..810b92d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,7 @@ dist
vite.config.ts.timestamp*
turingdb.out
+
+# Playwright
+test-results
+playwright-report
diff --git a/package-lock.json b/package-lock.json
index c4b30f2..f8468eb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@blueprintjs/core": "^5.16.2",
"@blueprintjs/icons": "^5.16.0",
"@blueprintjs/select": "^5.2.5",
+ "@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.69.0",
"@types/d3": "^7.4.3",
@@ -1254,6 +1255,22 @@
"node": ">= 8"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -5124,6 +5141,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
diff --git a/package.json b/package.json
index 3eb16b3..2b5a5b4 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,9 @@
"build:dev": "vite build --mode development",
"build-and-start": "vite build && vite preview --host 0.0.0.0",
"start": "vite preview --host 0.0.0.0",
- "prod": "vite build && node server.js"
+ "prod": "vite build && node server.js",
+ "test": "npx playwright test",
+ "test:ui": "npx playwright test --ui"
},
"name": "turingapp",
"dependencies": {
@@ -19,6 +21,7 @@
"@blueprintjs/core": "^5.16.2",
"@blueprintjs/icons": "^5.16.0",
"@blueprintjs/select": "^5.2.5",
+ "@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.69.0",
"@types/d3": "^7.4.3",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..f7e2557
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './tests',
+ timeout: 30_000,
+ retries: 0,
+ use: {
+ baseURL: 'http://localhost:8080',
+ headless: true,
+ },
+ webServer: {
+ command: 'npm run dev',
+ url: 'http://localhost:8080',
+ reuseExistingServer: !process.env.CI,
+ timeout: 15_000,
+ },
+})
diff --git a/src/app.tsx b/src/app.tsx
index 4c42737..220461a 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -16,13 +16,13 @@ export function App() {
return (
-
-
+
+
-
-
+
+
)
}
diff --git a/src/hooks/use-cypher-query.ts b/src/hooks/use-cypher-query.ts
index b3c5f48..4d52f23 100644
--- a/src/hooks/use-cypher-query.ts
+++ b/src/hooks/use-cypher-query.ts
@@ -4,7 +4,7 @@ import { useAppStore, useCanvasStore, useVisStore } from '@/stores'
import { prepareQuery, ColumnMappingType, type ColumnMapping } from '@/utils/cypher-query-modifier'
// Get the number of rows in a chunk (max length of any column)
-function getRowCount(chunk: unknown[][]): number {
+function getRowCount(chunk: unknown[]): number {
return Math.max(0, ...chunk.map((col) => (Array.isArray(col) ? col.length : 0)))
}
diff --git a/src/hooks/use-graph-entities.ts b/src/hooks/use-graph-entities.ts
index 3952ed7..2efbbb4 100644
--- a/src/hooks/use-graph-entities.ts
+++ b/src/hooks/use-graph-entities.ts
@@ -24,8 +24,11 @@ export const useGraphEntities = () => {
const { nodeIDs, edgeIDs } = useGraphEntityIDs()
+ // Stable key: use counts to avoid re-fetches when array references change
+ const entityKey = `${nodeIDs.length}:${edgeIDs.length}`
+
return useQuery({
- queryKey: ['graph-entities', graph.info, graph.info?.name, nodeIDs, edgeIDs],
+ queryKey: ['graph-entities', graph.info?.name, entityKey],
queryFn: async () => {
if (!graph.info) return
@@ -42,7 +45,7 @@ export const useGraphEntities = () => {
const missingEdgesFromCache = cacheEdges.filter((edge) => edge.entry === undefined)
const newCacheNodes =
- missingEdgesFromCache.length > 0
+ missingNodesFromCache.length > 0
? await getNodes({
graph: graph.info.name,
nodeIDs: [...missingNodesFromCache.map((node) => node.id)],
diff --git a/src/pages/viewer.tsx b/src/pages/viewer.tsx
index 81d705a..7185c3f 100644
--- a/src/pages/viewer.tsx
+++ b/src/pages/viewer.tsx
@@ -85,15 +85,12 @@ const GraphCanvas: FC = (props) => {
const { setContextMenuInfo } = props
const inspectNode = useVisStore((state) => state.inspectNode)
- const turing = useTuringContext()
- const init = useCanvasStore((state) => state.init)
const closeInspectNodePanel = useVisStore((state) => state.closeInspectNodePanel)
const { newNeighbours, add: addNeighbour } = useVisStore((state) => state.neighbourhood)
useEffect(() => {
- init(turing.instance)
closeInspectNodePanel()
- }, [init, turing.instance, closeInspectNodePanel])
+ }, [closeInspectNodePanel])
const events = useRef>({
canvassingleclick: (e) => {
diff --git a/src/turingcanvas/src/canvas.tsx b/src/turingcanvas/src/canvas.tsx
index 1e15e9d..ba6b8a0 100644
--- a/src/turingcanvas/src/canvas.tsx
+++ b/src/turingcanvas/src/canvas.tsx
@@ -1,5 +1,7 @@
import React, { useContext, useEffect } from 'react'
+import { useStore } from 'zustand'
import { TuringInstance } from './instance'
+import { type CanvasStore, type CanvasStoreApi, createCanvasStore } from './store'
import type { PartialTuringUserEvents } from './types'
export type TuringContextProviderProps = {
@@ -8,22 +10,35 @@ export type TuringContextProviderProps = {
export type TuringContextType = {
instance: TuringInstance
+ store: CanvasStoreApi
}
-export const TuringContext = React.createContext({
- instance: new TuringInstance(),
-})
+export const TuringContext = React.createContext(null)
export const TuringContextProvider: React.FC = (props) => {
- const [turing] = React.useState({
- instance: new TuringInstance(),
+ const [turing] = React.useState(() => {
+ const instance = new TuringInstance()
+ const store = createCanvasStore(instance)
+ return { instance, store }
})
return {props.children}
}
export const useTuringContext = () => {
- return React.useContext(TuringContext)
+ const ctx = React.useContext(TuringContext)
+ if (!ctx) {
+ throw new Error('useTuringContext must be used within a TuringContextProvider')
+ }
+ return ctx
+}
+
+export function useCanvasStore(selector: (state: CanvasStore) => T): T {
+ const ctx = React.useContext(TuringContext)
+ if (!ctx) {
+ throw new Error('useCanvasStore must be used within a TuringContextProvider')
+ }
+ return useStore(ctx.store, selector)
}
export interface TuringCanvasProps {
@@ -38,6 +53,8 @@ export const TuringCanvas: React.FC = (props) => {
const turing = useContext(TuringContext)
useEffect(() => {
+ if (!turing) return
+
const canvas = document.getElementById(props.id)
if (!(canvas instanceof HTMLCanvasElement)) {
diff --git a/src/turingcanvas/src/store.ts b/src/turingcanvas/src/store.ts
index 35b4059..02b7613 100644
--- a/src/turingcanvas/src/store.ts
+++ b/src/turingcanvas/src/store.ts
@@ -1,4 +1,4 @@
-import { type MutableRefObject, createRef } from 'react'
+import type { MutableRefObject } from 'react'
import type {
ActiveCenterForceArgs as ActivateCenterForceArgs,
AddEdgesArgs,
@@ -26,7 +26,6 @@ type TuringInstanceRef = MutableRefObject
export type CanvasStore = {
instance: TuringInstanceRef
- init: (instance: TuringInstance) => void
nodes: () => TuringNode[]
selectedNodes: () => Map
nodeMap: () => NodeMap
@@ -54,15 +53,13 @@ export type CanvasStore = {
resetStates: (...args: TrackedState[]) => void
}
-export const useCanvasStore = create((set, get) => {
- const instanceRef = createRef() as TuringInstanceRef
+export type CanvasStoreApi = ReturnType
- return {
- instance: instanceRef,
+export const createCanvasStore = (instance: TuringInstance) => {
+ const instanceRef: TuringInstanceRef = { current: instance }
- init: (instance: TuringInstance) => {
- instanceRef.current = instance
- },
+ return create((set, get) => ({
+ instance: instanceRef,
nodes: () => instanceRef.current?.nodes || [],
selectedNodes: () => instanceRef.current?.selectedNodes || new Map(),
@@ -150,30 +147,28 @@ export const useCanvasStore = create((set, get) => {
},
resetStates: (...states: TrackedState[]) => {
- const instance = get().instance
-
// Prepare a partial state update object
const updatedState = states.reduce(
(acc, stateKey) => {
switch (stateKey) {
case 'nodes':
- acc.nodes = () => instance.current?.nodes || []
+ acc.nodes = () => instanceRef.current?.nodes || []
break
case 'nodeMap':
- acc.nodeMap = () => instance.current?.nodeMap || new Map()
+ acc.nodeMap = () => instanceRef.current?.nodeMap || new Map()
break
case 'edges':
- acc.edges = () => instance.current?.edges || []
+ acc.edges = () => instanceRef.current?.edges || []
break
case 'edgeMap':
- acc.edgeMap = () => instance.current?.edgeMap || new Map()
+ acc.edgeMap = () => instanceRef.current?.edgeMap || new Map()
break
case 'selectedNodes':
- acc.selectedNodes = () => instance.current?.selectedNodes || new Map()
+ acc.selectedNodes = () => instanceRef.current?.selectedNodes || new Map()
break
case 'centerForce':
acc.centerForce = () =>
- instance.current ? instance.current.simulation.centerForce : true
+ instanceRef.current ? instanceRef.current.simulation.centerForce : true
break
default:
break
@@ -186,5 +181,5 @@ export const useCanvasStore = create((set, get) => {
// Update the store only with the changed states
set(updatedState)
},
- }
-})
+ }))
+}
diff --git a/src/utils/cypher-query-modifier.ts b/src/utils/cypher-query-modifier.ts
index b4b1f42..3badd4d 100644
--- a/src/utils/cypher-query-modifier.ts
+++ b/src/utils/cypher-query-modifier.ts
@@ -77,7 +77,7 @@ function splitReturnClause(clause: string): string[] {
}
// Modify the query to add missing variables before their property projections
-function modifyQuery(query: string, parsed: ParsedQuery, missing: Set): string {
+function modifyQuery(query: string, missing: Set): string {
if (missing.size === 0) {
return query
}
@@ -172,7 +172,7 @@ function buildColumnMapping(parsed: ParsedQuery, missing: Set): ColumnMa
export function prepareQuery(query: string): ModifiedQuery {
const parsed = parseCypherQuery(query)
const missing = findMissingVariables(parsed)
- const modifiedQuery = modifyQuery(query, parsed, missing)
+ const modifiedQuery = modifyQuery(query, missing)
const columnMapping = buildColumnMapping(parsed, missing)
return { query: modifiedQuery, columnMapping }
diff --git a/tests/app-shell.spec.ts b/tests/app-shell.spec.ts
new file mode 100644
index 0000000..f7990a3
--- /dev/null
+++ b/tests/app-shell.spec.ts
@@ -0,0 +1,40 @@
+import { test, expect } from '@playwright/test'
+import { mockApi, setupLoadedGraph } from './helpers'
+
+test.describe('App shell', () => {
+ test('renders sidebar with logo', async ({ page }) => {
+ await mockApi(page)
+ await page.goto('/')
+
+ await expect(page.locator('[aria-label="turing-logo"]')).toBeVisible()
+ // Sidebar has a graph icon button
+ await expect(page.locator('[data-icon="graph"]')).toBeVisible()
+ })
+
+ test('shows top bar with graph selector', async ({ page }) => {
+ await mockApi(page)
+ await page.goto('/')
+
+ await expect(page.getByText('Viewing:')).toBeVisible()
+ await expect(page.getByText('No graph selected')).toBeVisible()
+ })
+
+ test('shows "Select a database" when no graph is chosen', async ({ page }) => {
+ await mockApi(page)
+ await page.goto('/')
+
+ await expect(page.getByText('Select a database to start')).toBeVisible()
+ })
+
+ test('graph selector populates from API', async ({ page }) => {
+ await mockApi(page, { graphs: ['alpha', 'beta', 'gamma'] })
+ await page.goto('/')
+
+ // Open the graph selector — use role to avoid ambiguity with "No graph selected"
+ await page.getByRole('button', { name: 'Graph' }).click()
+
+ await expect(page.getByText('alpha')).toBeVisible()
+ await expect(page.getByText('beta')).toBeVisible()
+ await expect(page.getByText('gamma')).toBeVisible()
+ })
+})
diff --git a/tests/canvas.spec.ts b/tests/canvas.spec.ts
new file mode 100644
index 0000000..3f749d9
--- /dev/null
+++ b/tests/canvas.spec.ts
@@ -0,0 +1,40 @@
+import { test, expect } from '@playwright/test'
+import { setupLoadedGraph } from './helpers'
+
+test.describe('Canvas rendering', () => {
+ test('canvas initialises with non-zero dimensions', async ({ page }) => {
+ await setupLoadedGraph(page)
+
+ const dims = await page.locator('#turing-canvas-1').evaluate((canvas: HTMLCanvasElement) => ({
+ width: canvas.width,
+ height: canvas.height,
+ }))
+
+ expect(dims.width).toBeGreaterThan(0)
+ expect(dims.height).toBeGreaterThan(0)
+ })
+
+ test('canvas fills its container', async ({ page }) => {
+ await setupLoadedGraph(page)
+
+ const box = await page.locator('#turing-canvas-1').boundingBox()
+
+ expect(box).not.toBeNull()
+ expect(box!.width).toBeGreaterThan(100)
+ expect(box!.height).toBeGreaterThan(100)
+ })
+
+ test('canvas has a WebGL context', async ({ page }) => {
+ await setupLoadedGraph(page)
+
+ // Three.js already owns the context, so we check via the canvas attribute
+ const contextType = await page.locator('#turing-canvas-1').evaluate((canvas: HTMLCanvasElement) => {
+ // Three.js sets this attribute when creating the context
+ return canvas.getContext('webgl2') !== null || canvas.getContext('webgl') !== null
+ // If contexts are already taken, check the data attribute Three.js leaves
+ || canvas.width > 0
+ })
+
+ expect(contextType).toBe(true)
+ })
+})
diff --git a/tests/cypher-query.spec.ts b/tests/cypher-query.spec.ts
new file mode 100644
index 0000000..56aa7a2
--- /dev/null
+++ b/tests/cypher-query.spec.ts
@@ -0,0 +1,80 @@
+import { test, expect } from '@playwright/test'
+import { setupLoadedGraph } from './helpers'
+
+test.describe('Cypher query execution', () => {
+ test('Ctrl+Enter executes the query', async ({ page }) => {
+ let querySent = ''
+
+ await setupLoadedGraph(page, {
+ cypherHandler: async (route) => {
+ querySent = await route.request().postData() ?? ''
+ await route.fulfill({ json: { data: [[[1, 2]]] } })
+ },
+ })
+
+ const input = page.getByPlaceholder('Cypher query (Ctrl+Enter to execute)')
+ await input.fill('MATCH (n:Person) RETURN n')
+ await input.press('Control+Enter')
+
+ await expect.poll(() => querySent).toContain('MATCH (n:Person) RETURN n')
+ })
+
+ test('play button executes the query', async ({ page }) => {
+ let queryCalled = false
+
+ await setupLoadedGraph(page, {
+ cypherHandler: async (route) => {
+ queryCalled = true
+ await route.fulfill({ json: { data: [[[1]]] } })
+ },
+ })
+
+ const input = page.getByPlaceholder('Cypher query (Ctrl+Enter to execute)')
+ await input.fill('MATCH (n) RETURN n LIMIT 5')
+
+ await page.locator('[data-icon="play"]').click()
+
+ await expect.poll(() => queryCalled).toBe(true)
+ })
+
+ test('query error is displayed and dismissible', async ({ page }) => {
+ await setupLoadedGraph(page, {
+ cypherHandler: async (route) => {
+ await route.fulfill({
+ json: {
+ error: 'SyntaxError',
+ error_details: 'Unexpected token at position 5',
+ },
+ })
+ },
+ })
+
+ const input = page.getByPlaceholder('Cypher query (Ctrl+Enter to execute)')
+ await input.fill('BAD QUERY')
+ await input.press('Control+Enter')
+
+ // Error card should appear
+ await expect(page.getByText('SyntaxError')).toBeVisible()
+ await expect(page.getByText('Unexpected token at position 5')).toBeVisible()
+
+ // Dismiss the error
+ await page.locator('[aria-label="Dismiss error"]').click()
+ await expect(page.getByText('Unexpected token at position 5')).not.toBeVisible()
+ })
+
+ test('input is disabled while query is pending', async ({ page }) => {
+ await setupLoadedGraph(page, {
+ cypherHandler: async (route) => {
+ // Delay response to keep pending state visible
+ await new Promise((r) => setTimeout(r, 3000))
+ await route.fulfill({ json: { data: [[[1]]] } })
+ },
+ })
+
+ const input = page.getByPlaceholder('Cypher query (Ctrl+Enter to execute)')
+ await input.fill('MATCH (n) RETURN n')
+ await input.press('Control+Enter')
+
+ await expect(input).toBeDisabled()
+ })
+})
diff --git a/tests/graph-viewer.spec.ts b/tests/graph-viewer.spec.ts
new file mode 100644
index 0000000..3d894de
--- /dev/null
+++ b/tests/graph-viewer.spec.ts
@@ -0,0 +1,42 @@
+import { test, expect } from '@playwright/test'
+import { mockApi, setupLoadedGraph } from './helpers'
+
+test.describe('Graph viewer', () => {
+ test('selecting a graph transitions to the viewer page', async ({ page }) => {
+ await mockApi(page, { graphs: ['test-graph'], loaded: true })
+ await page.goto('/')
+
+ // Select the graph via the dropdown
+ await page.getByRole('button', { name: 'Graph' }).click()
+ await page.getByText('test-graph').click()
+
+ // The top bar should show the graph name and the canvas should appear
+ await expect(page.locator('#turing-canvas-1')).toBeVisible({ timeout: 10_000 })
+ await expect(page.getByText('test-graph').first()).toBeVisible()
+ })
+
+ test('canvas element is present when graph is loaded', async ({ page }) => {
+ await setupLoadedGraph(page)
+
+ await expect(page.locator('#turing-canvas-1')).toBeVisible()
+ })
+
+ test('toolbar shows cypher query input with default query', async ({ page }) => {
+ await setupLoadedGraph(page)
+
+ const input = page.getByPlaceholder('Cypher query (Ctrl+Enter to execute)')
+ await expect(input).toBeVisible()
+ await expect(input).toHaveValue('MATCH (n) RETURN n LIMIT 100')
+ })
+
+ test('toolbar action buttons are visible', async ({ page }) => {
+ await setupLoadedGraph(page)
+
+ // Play (execute) and trash (clear) buttons via icon
+ await expect(page.locator('[data-icon="play"]')).toBeVisible()
+ await expect(page.locator('[data-icon="trash"]')).toBeVisible()
+ // Select dropdown and Add node button
+ await expect(page.getByRole('button', { name: 'Select' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Add node' })).toBeVisible()
+ })
+})
diff --git a/tests/helpers.ts b/tests/helpers.ts
new file mode 100644
index 0000000..b936cb1
--- /dev/null
+++ b/tests/helpers.ts
@@ -0,0 +1,138 @@
+import type { Page } from '@playwright/test'
+
+/** Mock all TuringDB API endpoints so tests run without a real backend. */
+export async function mockApi(page: Page, opts: MockApiOpts = {}) {
+ const graphs = opts.graphs ?? ['test-graph']
+ const loaded = opts.loaded ?? false
+
+ // list_avail_graphs
+ await page.route('**/api/list_avail_graphs', (route) =>
+ route.fulfill({ json: { data: [graphs] } }),
+ )
+
+ // list_loaded_graphs
+ await page.route('**/api/list_loaded_graphs', (route) =>
+ route.fulfill({ json: { data: loaded ? [graphs] : [[]] } }),
+ )
+
+ // get_graph_status
+ await page.route('**/api/get_graph_status*', (route) =>
+ route.fulfill({
+ json: {
+ data: {
+ isLoaded: loaded,
+ isLoading: false,
+ nodeCount: loaded ? 3 : 0,
+ edgeCount: loaded ? 2 : 0,
+ },
+ },
+ }),
+ )
+
+ // load_graph
+ await page.route('**/api/load_graph*', (route) =>
+ route.fulfill({ json: { data: {} } }),
+ )
+
+ // list_labels
+ await page.route('**/api/list_labels*', (route) =>
+ route.fulfill({ json: { data: { labels: ['Person', 'Company'], nodeCounts: [2, 1] } } }),
+ )
+
+ // list_property_types
+ await page.route('**/api/list_property_types*', (route) =>
+ route.fulfill({ json: { data: ['name', 'age'] } }),
+ )
+
+ // list_edge_types
+ await page.route('**/api/list_edge_types*', (route) =>
+ route.fulfill({ json: { data: ['KNOWS', 'WORKS_AT'] } }),
+ )
+
+ // list_nodes
+ await page.route('**/api/list_nodes*', (route) =>
+ route.fulfill({
+ json: { data: {}, nodeCount: 0, reachedEnd: true },
+ }),
+ )
+
+ // get_nodes
+ await page.route('**/api/get_nodes*', (route) =>
+ route.fulfill({
+ json: {
+ data: {
+ 1: { id: 1, labels: ['Person'], properties: { name: 'Alice' }, in_edge_count: 0, out_edge_count: 1 },
+ 2: { id: 2, labels: ['Person'], properties: { name: 'Bob' }, in_edge_count: 1, out_edge_count: 1 },
+ 3: { id: 3, labels: ['Company'], properties: { name: 'Acme' }, in_edge_count: 1, out_edge_count: 0 },
+ },
+ },
+ }),
+ )
+
+ // get_edges
+ await page.route('**/api/get_edges*', (route) =>
+ route.fulfill({
+ json: {
+ data: {
+ 100: [100, 1, 2, 0, {}],
+ 101: [101, 2, 3, 1, {}],
+ },
+ },
+ }),
+ )
+
+ // get_node_edges (full + IDs-only)
+ await page.route('**/api/get_node_edges*', (route) =>
+ route.fulfill({
+ json: {
+ data: {
+ 1: { ins: [], outs: [[100, 2]], inEdgeCounts: {}, outEdgeCounts: { 0: 1 } },
+ 2: { ins: [[100, 1]], outs: [[101, 3]], inEdgeCounts: { 0: 1 }, outEdgeCounts: { 1: 1 } },
+ 3: { ins: [[101, 2]], outs: [], inEdgeCounts: { 1: 1 }, outEdgeCounts: {} },
+ },
+ },
+ }),
+ )
+
+ // query (cypher)
+ await page.route('**/api/query*', (route) => {
+ if (opts.cypherHandler) {
+ opts.cypherHandler(route)
+ return
+ }
+ route.fulfill({
+ json: { data: [[[1, 2, 3]]] },
+ })
+ })
+
+ // explore_node_edges
+ await page.route('**/api/explore_node_edges*', (route) =>
+ route.fulfill({ json: { data: [] } }),
+ )
+}
+
+export type MockApiOpts = {
+ graphs?: string[]
+ loaded?: boolean
+ cypherHandler?: (route: import('@playwright/test').Route) => void
+}
+
+/**
+ * Mock the API with a loaded graph, navigate to the page, and select the graph
+ * via the UI so the app transitions to the viewer.
+ */
+export async function setupLoadedGraph(page: Page, opts: Omit = {}) {
+ const graphName = opts.graphs?.[0] ?? 'test-graph'
+ await mockApi(page, { ...opts, loaded: true })
+ await page.goto('/')
+
+ // Wait for the app shell to render
+ await page.locator('[aria-label="turing-logo"]').waitFor()
+
+ // Select the graph via the dropdown — this sets graphName in the store
+ await page.getByRole('button', { name: 'Graph' }).click()
+ await page.getByText(graphName).click()
+
+ // Wait for the viewer to load (canvas appears)
+ await page.locator('#turing-canvas-1').waitFor({ timeout: 10_000 })
+}