From bc0aea61f93a999d6100f69e3a373ec2cc3a682b Mon Sep 17 00:00:00 2001 From: Thomas Hanke Date: Mon, 29 Jun 2026 09:22:57 +0200 Subject: [PATCH 1/2] fix(build): stub node:diagnostics_channel at Rollup level to fix SPARQL production crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lru-cache v11 imports node:diagnostics_channel which Vite's Rollup builder stubs as empty object — crashes worker at init with "f.channel is not a function". Extend workerComunicaPlugin with enforce:"pre" resolveId+load for a virtual ESM noop module covering all imports, not just those inside the esbuild prebundle. Also fix 6 broken ToC anchor links in README by adding id attrs to
tags. --- README.md | 12 ++++++------ vite-plugin-worker-comunica.ts | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0d9935d9..d3f2a29c 100644 --- a/README.md +++ b/README.md @@ -540,7 +540,7 @@ Full tool declarations with input schemas: [public/.well-known/mcp.json](public/ Developer ========= -
+
Quick start (development) 1. Install dependencies: @@ -614,7 +614,7 @@ node scripts/run-demo.mjs docs/mcp-demo/seeds/pizza-tutorial.md
-
+
Reasoning demo (OWL 2 DL patterns) The reasoning demo showcases OWL 2 DL / SROIQ(D) inference on a small employee ontology: @@ -648,7 +648,7 @@ A separate **inconsistency demo** (`public/reasoning-demo-inconsistent.ttl`) sho
-
+
CORS and proxies Ontosphere fetches remote RDF directly from the browser. If the remote host does not allow cross-origin requests, the fetch will be blocked. @@ -668,7 +668,7 @@ Workarounds for development:
-
+
Developer utilities (window globals) The following debug flags can be set in the browser console to enable diagnostic output. All are gated — they only activate when `window.__VG_DEBUG__` is truthy (or `config.debugAll` is enabled in Settings): @@ -682,7 +682,7 @@ All flags are also persisted from `config.debugAll` (toggleable in Settings →
-
+
Troubleshooting - **rdfUrl doesn't load on open:** @@ -698,7 +698,7 @@ All flags are also persisted from `config.debugAll` (toggleable in Settings →
-
+
Recording demo videos See [docs/demo-scripts/HOWTO.md](docs/demo-scripts/HOWTO.md) for the full guide. All videos are listed in [Video tutorials](#video-tutorials) above. diff --git a/vite-plugin-worker-comunica.ts b/vite-plugin-worker-comunica.ts index f3bec4f9..07b74810 100644 --- a/vite-plugin-worker-comunica.ts +++ b/vite-plugin-worker-comunica.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const VIRTUAL_ID = "\0virtual:comunica-prebundled"; +const DIAG_STUB_ID = "\0virtual:node-diagnostics-channel-stub"; const COMUNICA_PKG = "@comunica/query-sparql-rdfjs"; /** @@ -45,6 +46,24 @@ export const diagnosticsChannelStub: EsbuildPlugin = { }, }; +const DIAG_CHANNEL_ESM_STUB = ` +const noop = () => {}; +const noopChannel = () => ({ + hasSubscribers: false, + subscribe: noop, + unsubscribe: noop, + publish: () => false, + bindStore: noop, + unbindStore: noop, +}); +export const channel = noopChannel; +export const tracingChannel = noopChannel; +export const hasSubscribers = () => false; +export const subscribe = noop; +export const unsubscribe = noop; +export default { channel: noopChannel, tracingChannel: noopChannel, hasSubscribers: () => false, subscribe: noop, unsubscribe: noop }; +`; + /** * Pre-bundles Comunica with esbuild when building the web worker. * @@ -82,12 +101,17 @@ export function workerComunicaPlugin(): Plugin { prebundled = result.outputFiles[0].text; }, + enforce: "pre" as const, + resolveId(id: string) { if (id === COMUNICA_PKG) return VIRTUAL_ID; + if (id === "node:diagnostics_channel" || id === "diagnostics_channel") + return DIAG_STUB_ID; }, load(id: string) { if (id === VIRTUAL_ID) return prebundled ?? ""; + if (id === DIAG_STUB_ID) return DIAG_CHANNEL_ESM_STUB; }, }; } From e5aa0d400bc2067549177251f1f4ec5438e76536 Mon Sep 17 00:00:00 2001 From: Thomas Hanke Date: Mon, 29 Jun 2026 09:43:38 +0200 Subject: [PATCH 2/2] fix(e2e): replace fragile waitForReady with COI-reload-safe helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COI service worker can reload the page after goto, wiping the worker store. The old per-call retry only re-ran the interrupted call — earlier seeded data was lost. New shared helpers in e2e-helpers.ts: - gotoAndWaitForReady: absorbs COI reload before returning - seedWithRetry: re-runs entire seed sequence if reload detected mid-seed - callTool: retry wrapper with proper re-stabilisation Migrated reasoning-named-restriction, reasoning-inconsistency, sparql-worker. --- e2e/e2e-helpers.ts | 127 ++++++++++++++++++++++++ e2e/reasoning-inconsistency.spec.ts | 66 +++--------- e2e/reasoning-named-restriction.spec.ts | 117 +++++++++------------- e2e/sparql-worker.spec.ts | 39 ++------ 4 files changed, 198 insertions(+), 151 deletions(-) create mode 100644 e2e/e2e-helpers.ts diff --git a/e2e/e2e-helpers.ts b/e2e/e2e-helpers.ts new file mode 100644 index 00000000..a234d44b --- /dev/null +++ b/e2e/e2e-helpers.ts @@ -0,0 +1,127 @@ +/** + * Shared e2e helpers — reliable page readiness + MCP tool calls. + * + * The COI service worker (`coi-serviceworker.js`) may reload the page shortly + * after `page.goto()`. A naive `waitForFunction(__mcpTools)` can pass on the + * first (pre-reload) page, then seeded data is lost when the reload wipes the + * worker store. These helpers absorb the reload before returning. + */ + +import type { Page } from '@playwright/test'; + +/** + * Navigate to `url` and wait until the page is fully stable: + * 1. COI service-worker reload has been absorbed (or timed out) + * 2. `window.crossOriginIsolated === true` + * 3. `window.__mcpTools` is registered and the requested tool exists + * + * Call this instead of bare `page.goto()` + `waitForReady()`. + */ +export async function gotoAndWaitForReady( + page: Page, + url: string, + requiredTool = 'addNode', +): Promise { + await page.goto(url); + + // Absorb a potential COI service-worker reload: wait briefly for a + // navigation event. If none happens within 3s, the page is stable. + try { + await page.waitForNavigation({ timeout: 3_000, waitUntil: 'load' }); + } catch { + // No reload — that's fine. + } + + // Now wait for the app to be fully ready on the (possibly reloaded) page. + await page.waitForFunction( + (tool: string) => + window.crossOriginIsolated === true && + !!(window as any).__mcpTools && + typeof (window as any).__mcpTools[tool] === 'function', + requiredTool, + { timeout: 30_000 }, + ); +} + +/** + * Call an MCP tool via `window.__mcpTools`, retrying once if a COI reload + * destroys the execution context mid-call. + * + * On retry, calls `gotoAndWaitForReady` to re-stabilise the page. The caller + * must handle the fact that earlier seeded data may be lost on reload — wrap + * the entire seed sequence in a retry if needed. + */ +export async function callTool( + page: Page, + tool: string, + params: object, + baseUrl?: string, +): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + try { + return await page.evaluate( + ([t, p]) => (window as any).__mcpTools[t](p), + [tool, params] as const, + ); + } catch (err: any) { + if (attempt === 0 && /context was destroyed|navigat/i.test(err.message)) { + if (baseUrl) { + await gotoAndWaitForReady(page, baseUrl, tool); + } else { + // Fallback: just wait for the new page to be ready + await page.waitForFunction( + (t: string) => + window.crossOriginIsolated === true && + !!(window as any).__mcpTools && + typeof (window as any).__mcpTools[t] === 'function', + tool, + { timeout: 30_000 }, + ); + } + continue; + } + throw err; + } + } +} + +/** + * Run the full seed sequence inside a retry loop. If a COI reload wipes the + * store mid-seed, the entire sequence re-runs on the fresh page. + */ +export async function seedWithRetry( + page: Page, + baseUrl: string, + seedFn: (call: (tool: string, params: object) => Promise) => Promise, + maxAttempts = 2, +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + let reloaded = false; + const wrappedCall = async (tool: string, params: object): Promise => { + try { + return await page.evaluate( + ([t, p]) => (window as any).__mcpTools[t](p), + [tool, params] as const, + ); + } catch (err: any) { + if (/context was destroyed|navigat/i.test(err.message)) { + reloaded = true; + throw err; + } + throw err; + } + }; + + try { + await seedFn(wrappedCall); + return; // All calls succeeded — no reload detected. + } catch (err: any) { + if (reloaded && attempt < maxAttempts - 1) { + // Store was wiped by reload. Re-navigate and re-seed. + await gotoAndWaitForReady(page, baseUrl); + continue; + } + throw err; + } + } +} diff --git a/e2e/reasoning-inconsistency.spec.ts b/e2e/reasoning-inconsistency.spec.ts index 10f38ff4..eca9054e 100644 --- a/e2e/reasoning-inconsistency.spec.ts +++ b/e2e/reasoning-inconsistency.spec.ts @@ -10,50 +10,20 @@ * Requires: npm run dev (http://localhost:8080) with SharedArrayBuffer enabled. */ -import { test, expect, Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; +import { gotoAndWaitForReady, callTool } from './e2e-helpers.js'; const BASE_URL = (process.env.VG_URL ?? 'http://localhost:8080') + '?ontologies='; -async function waitForReady(page: Page) { - await page.waitForFunction( - () => - window.crossOriginIsolated !== false && - !!(window as any).__mcpTools && - typeof (window as any).__mcpTools['loadRdf'] === 'function', - { timeout: 30_000 }, - ); -} - -async function call(page: Page, tool: string, params: object): Promise { - // CI: Vite HMR or COI service worker can reload the page after the initial - // load. page.evaluate fails with "Execution context was destroyed" when this - // happens mid-call. Retry once after waiting for the new page to be ready. - for (let attempt = 0; attempt < 2; attempt++) { - try { - return await page.evaluate( - ([t, p]) => (window as any).__mcpTools[t](p), - [tool, params] as const, - ); - } catch (err: any) { - if (attempt === 0 && /context was destroyed|navigat/i.test(err.message)) { - await waitForReady(page); - continue; - } - throw err; - } - } -} - test('MCP runReasoning: inconsistent TTL → isConsistent=false, errors with frank nodeId, inferredTriples=0', async ({ page }) => { - await page.goto(BASE_URL); - await waitForReady(page); + await gotoAndWaitForReady(page, BASE_URL, 'loadRdf'); const turtle = fs.readFileSync(path.resolve('public/reasoning-demo-inconsistent.ttl'), 'utf-8'); - await call(page, 'loadRdf', { turtle }); + await callTool(page, 'loadRdf', { turtle }, BASE_URL); - const result = await call(page, 'runReasoning', {}) as any; + const result = await callTool(page, 'runReasoning', {}, BASE_URL) as any; console.log('[TEST] runReasoning result:', JSON.stringify(result?.data)); expect(result?.success).toBe(true); @@ -67,12 +37,11 @@ test('MCP runReasoning: inconsistent TTL → isConsistent=false, errors with fra }); test('TopBar indicator: inconsistent TTL → button shows Inconsistent', async ({ page }) => { - await page.goto(BASE_URL); - await waitForReady(page); + await gotoAndWaitForReady(page, BASE_URL, 'loadRdf'); const turtle = fs.readFileSync(path.resolve('public/reasoning-demo-inconsistent.ttl'), 'utf-8'); - await call(page, 'loadRdf', { turtle }); - await call(page, 'runReasoning', {}); + await callTool(page, 'loadRdf', { turtle }, BASE_URL); + await callTool(page, 'runReasoning', {}, BASE_URL); const button = page.locator('button.glass-btn--status-error'); await button.waitFor({ timeout: 30_000 }); @@ -82,18 +51,16 @@ test('TopBar indicator: inconsistent TTL → button shows Inconsistent', async ( }); test('Modal content: Summary shows OWL DL card, Errors tab shows affected node', async ({ page }) => { - // Konclude WASM explainInconsistency (blackbox MIPS) can be slow on CI; - // a COI service-worker reload may also occur mid-sequence. Allow extra time. + // Konclude WASM explainInconsistency (blackbox MIPS) can be slow on CI. test.setTimeout(120_000); - await page.goto(BASE_URL); - await waitForReady(page); + await gotoAndWaitForReady(page, BASE_URL, 'loadRdf'); const turtle = fs.readFileSync(path.resolve('public/reasoning-demo-inconsistent.ttl'), 'utf-8'); async function loadAndRun() { - await call(page, 'loadRdf', { turtle }); - await call(page, 'runReasoning', {}); + await callTool(page, 'loadRdf', { turtle }, BASE_URL); + await callTool(page, 'runReasoning', {}, BASE_URL); const button = page.locator('button.glass-btn--status-error'); await button.waitFor({ timeout: 30_000 }); await button.click(); @@ -107,7 +74,7 @@ test('Modal content: Summary shows OWL DL card, Errors tab shows affected node', } catch { // COI service-worker may have reloaded the page after button click — // redo the data load + reasoning + click on the fresh page. - await waitForReady(page); + await gotoAndWaitForReady(page, BASE_URL, 'loadRdf'); await loadAndRun(); await dialog.waitFor({ timeout: 60_000 }); } @@ -124,13 +91,12 @@ test('Modal content: Summary shows OWL DL card, Errors tab shows affected node', }); test('Consistent sanity: reasoning-demo.ttl → isConsistent=true, errors=0, inferredTriples>0, TopBar Valid', async ({ page }) => { - await page.goto(BASE_URL); - await waitForReady(page); + await gotoAndWaitForReady(page, BASE_URL, 'loadRdf'); const turtle = fs.readFileSync(path.resolve('public/reasoning-demo.ttl'), 'utf-8'); - await call(page, 'loadRdf', { turtle }); + await callTool(page, 'loadRdf', { turtle }, BASE_URL); - const result = await call(page, 'runReasoning', {}) as any; + const result = await callTool(page, 'runReasoning', {}, BASE_URL) as any; console.log('[TEST] consistent runReasoning result:', JSON.stringify(result?.data)); expect(result?.success).toBe(true); diff --git a/e2e/reasoning-named-restriction.spec.ts b/e2e/reasoning-named-restriction.spec.ts index 50e27789..7c3dbff1 100644 --- a/e2e/reasoning-named-restriction.spec.ts +++ b/e2e/reasoning-named-restriction.spec.ts @@ -11,43 +11,16 @@ * Requires: npm run dev (http://localhost:8080) */ -import { test, expect, Page } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import { gotoAndWaitForReady, callTool, seedWithRetry } from './e2e-helpers.js'; const BASE_URL = process.env.VG_URL ?? 'http://localhost:8080'; const EX = 'http://example.org/collapse-test#'; const OWL = 'http://www.w3.org/2002/07/owl#'; const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; -async function waitForReady(page: Page) { - await page.waitForFunction( - () => - window.crossOriginIsolated !== false && - !!(window as any).__mcpTools && - typeof (window as any).__mcpTools['addNode'] === 'function', - { timeout: 30_000 }, - ); -} - -async function call(page: Page, tool: string, params: object): Promise { - for (let attempt = 0; attempt < 2; attempt++) { - try { - return await page.evaluate( - ([t, p]) => (window as any).__mcpTools[t](p), - [tool, params] as const, - ); - } catch (err: any) { - if (attempt === 0 && /context was destroyed|navigat/i.test(err.message)) { - await waitForReady(page); - continue; - } - throw err; - } - } -} - test('loadRdf blank-node restrictions: skolemized on canvas, reasoning classifies correctly', async ({ page }) => { - await page.goto(BASE_URL); - await waitForReady(page); + await gotoAndWaitForReady(page, BASE_URL, 'loadRdf'); // NB: use the `ct:` prefix, not `ex:` — the app reserves `ex:` for // http://example.org/ (iriUtils built-in), which would override this @@ -84,9 +57,9 @@ ct:ind1 rdf:type owl:NamedIndividual ; ct:p1 rdf:type ct:FillerA . `; - await call(page, 'loadRdf', { turtle }); + await callTool(page, 'loadRdf', { turtle }, BASE_URL); - const linksResult = await call(page, 'getLinks', { limit: 500 }) as any; + const linksResult = await callTool(page, 'getLinks', { limit: 500 }, BASE_URL) as any; const dataQuads: Array<{ subject: string; predicate: string; object: string }> = linksResult?.data?.links ?? []; @@ -99,9 +72,9 @@ ct:p1 rdf:type ct:FillerA . expect(skolemNodes.length).toBeGreaterThan(0); // Run reasoning and verify ind1 → ClassA only - await call(page, 'runReasoning', { rulesets: ['owl-rl.n3'] }); + await callTool(page, 'runReasoning', { rulesets: ['owl-rl.n3'] }, BASE_URL); - const details = await call(page, 'getNodeDetails', { iri: `${EX}ind1` }) as any; + const details = await callTool(page, 'getNodeDetails', { iri: `${EX}ind1` }, BASE_URL) as any; const types: string[] = details?.data?.types ?? []; // getNodeDetails returns CURIE/abbreviated forms (e.g. "ct:ClassA"), so compare @@ -112,45 +85,45 @@ ct:p1 rdf:type ct:FillerA . }); test('named restriction nodes: MCP seeds correct triples and reasoning classifies correctly', async ({ page }) => { - await page.goto(BASE_URL); - await waitForReady(page); - - // ── Seed TBox: named restriction nodes ───────────────────────────────── - - // R1: someValuesFrom FillerA - await call(page, 'addNode', { iri: `${EX}R1`, typeIri: `${OWL}Restriction` }); - await call(page, 'addTriple', { subjectIri: `${EX}R1`, predicateIri: `${OWL}onProperty`, objectIri: `${EX}hasPart` }); - await call(page, 'addTriple', { subjectIri: `${EX}R1`, predicateIri: `${OWL}someValuesFrom`, objectIri: `${EX}FillerA` }); - - // R2: someValuesFrom FillerB (same onProperty, different filler) - await call(page, 'addNode', { iri: `${EX}R2`, typeIri: `${OWL}Restriction` }); - await call(page, 'addTriple', { subjectIri: `${EX}R2`, predicateIri: `${OWL}onProperty`, objectIri: `${EX}hasPart` }); - await call(page, 'addTriple', { subjectIri: `${EX}R2`, predicateIri: `${OWL}someValuesFrom`, objectIri: `${EX}FillerB` }); - - // ClassA ≡ R1, ClassB ≡ R2 - await call(page, 'addNode', { iri: `${EX}ClassA`, typeIri: `${OWL}Class` }); - await call(page, 'addNode', { iri: `${EX}ClassB`, typeIri: `${OWL}Class` }); - await call(page, 'addTriple', { subjectIri: `${EX}ClassA`, predicateIri: `${OWL}equivalentClass`, objectIri: `${EX}R1` }); - await call(page, 'addTriple', { subjectIri: `${EX}ClassB`, predicateIri: `${OWL}equivalentClass`, objectIri: `${EX}R2` }); - - // Declare the filler classes and the object property. Konclude only performs - // ABox individual classification for declared classes carrying equivalentClass - // restrictions; without these declarations realization fires the TBox hierarchy - // but skips ind1 → ClassA. - await call(page, 'addNode', { iri: `${EX}hasPart`, typeIri: `${OWL}ObjectProperty` }); - await call(page, 'addNode', { iri: `${EX}FillerA`, typeIri: `${OWL}Class` }); - await call(page, 'addNode', { iri: `${EX}FillerB`, typeIri: `${OWL}Class` }); - - // ── Seed ABox ─────────────────────────────────────────────────────────── - - // ind1 hasPart p1; p1 type FillerA only - await call(page, 'addNode', { iri: `${EX}ind1`, typeIri: `${OWL}NamedIndividual` }); - await call(page, 'addNode', { iri: `${EX}p1`, typeIri: `${EX}FillerA` }); - await call(page, 'addTriple', { subjectIri: `${EX}ind1`, predicateIri: `${EX}hasPart`, objectIri: `${EX}p1` }); + await gotoAndWaitForReady(page, BASE_URL); + + // Seed via addNode/addTriple — wrapped in seedWithRetry so a COI reload + // mid-sequence re-navigates and re-seeds from scratch. + await seedWithRetry(page, BASE_URL, async (call) => { + // ── Seed TBox: named restriction nodes ───────────────────────────────── + + // R1: someValuesFrom FillerA + await call('addNode', { iri: `${EX}R1`, typeIri: `${OWL}Restriction` }); + await call('addTriple', { subjectIri: `${EX}R1`, predicateIri: `${OWL}onProperty`, objectIri: `${EX}hasPart` }); + await call('addTriple', { subjectIri: `${EX}R1`, predicateIri: `${OWL}someValuesFrom`, objectIri: `${EX}FillerA` }); + + // R2: someValuesFrom FillerB (same onProperty, different filler) + await call('addNode', { iri: `${EX}R2`, typeIri: `${OWL}Restriction` }); + await call('addTriple', { subjectIri: `${EX}R2`, predicateIri: `${OWL}onProperty`, objectIri: `${EX}hasPart` }); + await call('addTriple', { subjectIri: `${EX}R2`, predicateIri: `${OWL}someValuesFrom`, objectIri: `${EX}FillerB` }); + + // ClassA ≡ R1, ClassB ≡ R2 + await call('addNode', { iri: `${EX}ClassA`, typeIri: `${OWL}Class` }); + await call('addNode', { iri: `${EX}ClassB`, typeIri: `${OWL}Class` }); + await call('addTriple', { subjectIri: `${EX}ClassA`, predicateIri: `${OWL}equivalentClass`, objectIri: `${EX}R1` }); + await call('addTriple', { subjectIri: `${EX}ClassB`, predicateIri: `${OWL}equivalentClass`, objectIri: `${EX}R2` }); + + // Declare the filler classes and the object property. + await call('addNode', { iri: `${EX}hasPart`, typeIri: `${OWL}ObjectProperty` }); + await call('addNode', { iri: `${EX}FillerA`, typeIri: `${OWL}Class` }); + await call('addNode', { iri: `${EX}FillerB`, typeIri: `${OWL}Class` }); + + // ── Seed ABox ─────────────────────────────────────────────────────────── + + // ind1 hasPart p1; p1 type FillerA only + await call('addNode', { iri: `${EX}ind1`, typeIri: `${OWL}NamedIndividual` }); + await call('addNode', { iri: `${EX}p1`, typeIri: `${EX}FillerA` }); + await call('addTriple', { subjectIri: `${EX}ind1`, predicateIri: `${EX}hasPart`, objectIri: `${EX}p1` }); + }); // ── Dump urn:vg:data to inspect what actually landed ─────────────────── - const linksResult = await call(page, 'getLinks', { limit: 500 }) as any; + const linksResult = await callTool(page, 'getLinks', { limit: 500 }, BASE_URL) as any; const dataQuads: Array<{ subject: string; predicate: string; object: string }> = linksResult?.data?.links ?? []; @@ -174,12 +147,12 @@ test('named restriction nodes: MCP seeds correct triples and reasoning classifie // ── Run OWL-RL reasoning ──────────────────────────────────────────────── - const reasoningResult = await call(page, 'runReasoning', { rulesets: ['owl-rl.n3'] }); + const reasoningResult = await callTool(page, 'runReasoning', { rulesets: ['owl-rl.n3'] }, BASE_URL); console.log('[TEST] Reasoning result:', JSON.stringify(reasoningResult)); // ── Inspect ind1 after reasoning ──────────────────────────────────────── - const details = await call(page, 'getNodeDetails', { iri: `${EX}ind1` }) as any; + const details = await callTool(page, 'getNodeDetails', { iri: `${EX}ind1` }, BASE_URL) as any; console.log('[TEST] ind1 details after reasoning:', JSON.stringify(details)); const types: string[] = details?.data?.types ?? []; diff --git a/e2e/sparql-worker.spec.ts b/e2e/sparql-worker.spec.ts index 47ff5b76..8868a267 100644 --- a/e2e/sparql-worker.spec.ts +++ b/e2e/sparql-worker.spec.ts @@ -12,42 +12,23 @@ * Requires a running dev server on http://localhost:8080 (npm run dev). */ -import { test, expect, Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; +import { gotoAndWaitForReady, callTool } from "./e2e-helpers.js"; const BASE_URL = process.env.VG_URL ?? "http://localhost:8080"; -async function waitForTools(page: Page): Promise { - await page.waitForFunction(() => { - const tools = (window as any).__mcpTools; - return window.crossOriginIsolated !== false && - tools && typeof tools.queryGraph === "function"; - }, { timeout: 30_000 }); -} - -async function qg(page: Page, sparql: string, limit?: number): Promise { - for (let attempt = 0; attempt < 2; attempt++) { - try { - return await page.evaluate( - async ({ sparql, limit }: { sparql: string; limit?: number }) => { - const queryGraph = (window as any).__mcpTools.queryGraph; - return queryGraph({ sparql, ...(limit !== undefined ? { limit } : {}) }); - }, - { sparql, limit }, - ); - } catch (err: any) { - if (attempt === 0 && /context was destroyed|navigat/i.test(err.message)) { - await waitForTools(page); - continue; - } - throw err; - } - } +async function qg(page: import("@playwright/test").Page, sparql: string, limit?: number): Promise { + return callTool( + page, + "queryGraph", + { sparql, ...(limit !== undefined ? { limit } : {}) }, + BASE_URL, + ); } test.describe("queryGraph browser worker", () => { test.beforeEach(async ({ page }) => { - await page.goto(BASE_URL); - await waitForTools(page); + await gotoAndWaitForReady(page, BASE_URL, "queryGraph"); }); test("INSERT DATA succeeds", async ({ page }) => {