Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ Full tool declarations with input schemas: [public/.well-known/mcp.json](public/
Developer
=========

<details>
<details id="quick-start-development">
<summary><strong>Quick start (development)</strong></summary>

1. Install dependencies:
Expand Down Expand Up @@ -614,7 +614,7 @@ node scripts/run-demo.mjs docs/mcp-demo/seeds/pizza-tutorial.md

</details>

<details>
<details id="reasoning-demo-owl-2-dl-patterns">
<summary><strong>Reasoning demo (OWL 2 DL patterns)</strong></summary>

The reasoning demo showcases OWL 2 DL / SROIQ(D) inference on a small employee ontology:
Expand Down Expand Up @@ -648,7 +648,7 @@ A separate **inconsistency demo** (`public/reasoning-demo-inconsistent.ttl`) sho

</details>

<details>
<details id="cors-and-proxies">
<summary><strong>CORS and proxies</strong></summary>

Ontosphere fetches remote RDF directly from the browser. If the remote host does not allow cross-origin requests, the fetch will be blocked.
Expand All @@ -668,7 +668,7 @@ Workarounds for development:

</details>

<details>
<details id="developer-utilities-window-globals">
<summary><strong>Developer utilities (window globals)</strong></summary>

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):
Expand All @@ -682,7 +682,7 @@ All flags are also persisted from `config.debugAll` (toggleable in Settings →

</details>

<details>
<details id="troubleshooting">
<summary><strong>Troubleshooting</strong></summary>

- **rdfUrl doesn't load on open:**
Expand All @@ -698,7 +698,7 @@ All flags are also persisted from `config.debugAll` (toggleable in Settings →

</details>

<details>
<details id="recording-demo-videos">
<summary><strong>Recording demo videos</strong></summary>

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.
Expand Down
127 changes: 127 additions & 0 deletions e2e/e2e-helpers.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<any> {
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<any>) => Promise<void>,
maxAttempts = 2,
): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let reloaded = false;
const wrappedCall = async (tool: string, params: object): Promise<any> => {
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;
}
}
}
66 changes: 16 additions & 50 deletions e2e/reasoning-inconsistency.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> {
// 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);
Expand All @@ -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 });
Expand All @@ -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();
Expand All @@ -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 });
}
Expand All @@ -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);
Expand Down
Loading
Loading