From a708d0b301f639f1a975e0f649c4cfcd093de335 Mon Sep 17 00:00:00 2001 From: Remy Boutonnet Date: Thu, 16 Apr 2026 12:48:08 +0200 Subject: [PATCH 1/2] Put state per canvas --- .gitignore | 4 + package-lock.json | 64 +++++++++++++ package.json | 5 +- playwright.config.ts | 17 ++++ src/app.tsx | 8 +- src/hooks/use-cypher-query.ts | 2 +- src/pages/viewer.tsx | 5 +- src/turingcanvas/src/canvas.tsx | 29 ++++-- src/turingcanvas/src/store.ts | 33 +++---- src/utils/cypher-query-modifier.ts | 4 +- tests/app-shell.spec.ts | 40 +++++++++ tests/canvas.spec.ts | 40 +++++++++ tests/cypher-query.spec.ts | 80 +++++++++++++++++ tests/graph-viewer.spec.ts | 42 +++++++++ tests/helpers.ts | 138 +++++++++++++++++++++++++++++ 15 files changed, 474 insertions(+), 37 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/app-shell.spec.ts create mode 100644 tests/canvas.spec.ts create mode 100644 tests/cypher-query.spec.ts create mode 100644 tests/graph-viewer.spec.ts create mode 100644 tests/helpers.ts 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/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 }) +} From 061c71253b072175c8b525a7c7be7fefbfe837bd Mon Sep 17 00:00:00 2001 From: Remy Boutonnet Date: Thu, 16 Apr 2026 22:11:27 +0200 Subject: [PATCH 2/2] Fix node fetch condition and stabilize React Query key - Fix bug: node fetch was gated on missingEdgesFromCache instead of missingNodesFromCache, skipping node fetches when there were no missing edges - Stabilize query key to use entity counts instead of full ID arrays, preventing infinite refetch loops with large datasets --- src/hooks/use-graph-entities.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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)],