diff --git a/e2e/sparql-worker.spec.ts b/e2e/sparql-worker.spec.ts new file mode 100644 index 00000000..d6b9f267 --- /dev/null +++ b/e2e/sparql-worker.spec.ts @@ -0,0 +1,107 @@ +/** + * Browser-level regression test for queryGraph MCP tool. + * + * Verifies that Comunica's QueryEngine initialises and executes correctly inside + * the Vite web worker — the failure mode this test guards against is the Rollup / + * esbuild CJS-circular-dep issue that previously caused every queryGraph call to + * return an error at runtime even though unit tests (InProcessWorker) passed. + * + * Run: + * npx playwright test e2e/sparql-worker.spec.ts + * + * Requires a running dev server on http://localhost:8080 (npm run dev). + */ + +import { test, expect, Page } from "@playwright/test"; + +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 tools && typeof tools.queryGraph === "function"; + }, { timeout: 10_000 }); +} + +async function qg(page: Page, sparql: string, limit?: number) { + return page.evaluate( + async ({ sparql, limit }: { sparql: string; limit?: number }) => { + const queryGraph = (window as any).__mcpTools.queryGraph; + return queryGraph({ sparql, ...(limit !== undefined ? { limit } : {}) }); + }, + { sparql, limit }, + ); +} + +test.describe("queryGraph browser worker", () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL); + await waitForTools(page); + }); + + test("INSERT DATA succeeds", async ({ page }) => { + const result = await qg( + page, + "INSERT DATA { }", + ); + expect(result.success).toBe(true); + expect(result.data.updated).toBe(true); + }); + + test("SELECT returns inserted triple", async ({ page }) => { + await qg( + page, + "INSERT DATA { }", + ); + const result = await qg( + page, + "SELECT ?s ?p ?o WHERE { ?p ?o } LIMIT 1", + ); + expect(result.success).toBe(true); + expect(result.data.rows).toHaveLength(1); + expect(result.data.rows[0].p).toBe("urn:sparql-test:p"); + expect(result.data.rows[0].o).toBe("urn:sparql-test:o"); + }); + + test("CONSTRUCT returns triples", async ({ page }) => { + await qg( + page, + "INSERT DATA { }", + ); + const result = await qg( + page, + "CONSTRUCT { ?s ?o } WHERE { ?s ?o }", + ); + expect(result.success).toBe(true); + expect(result.data.triples.length).toBeGreaterThanOrEqual(1); + const hit = result.data.triples.find( + (t: any) => t.s === "urn:sparql-test:s", + ); + expect(hit).toBeDefined(); + expect(hit.p).toBe("urn:sparql-test:p"); + }); + + test("DELETE DATA removes triple; follow-up SELECT returns 0 rows", async ({ page }) => { + await qg( + page, + "INSERT DATA { }", + ); + await qg( + page, + "DELETE DATA { }", + ); + const result = await qg( + page, + "SELECT ?o WHERE { ?o }", + ); + expect(result.success).toBe(true); + expect(result.data.rows).toHaveLength(0); + }); + + test("malformed SPARQL returns success:false with error string", async ({ page }) => { + const result = await qg(page, "NOT VALID SPARQL !!!"); + expect(result.success).toBe(false); + expect(typeof result.error).toBe("string"); + expect(result.error.length).toBeGreaterThan(0); + }); +}); diff --git a/vite-plugin-worker-comunica.ts b/vite-plugin-worker-comunica.ts new file mode 100644 index 00000000..f3bec4f9 --- /dev/null +++ b/vite-plugin-worker-comunica.ts @@ -0,0 +1,93 @@ +import { build, type Plugin as EsbuildPlugin } from "esbuild"; +import type { Plugin } from "vite"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const VIRTUAL_ID = "\0virtual:comunica-prebundled"; +const COMUNICA_PKG = "@comunica/query-sparql-rdfjs"; + +/** + * esbuild plugin that stubs `node:diagnostics_channel` for the browser. + * + * lru-cache (used by Comunica) imports this Node.js built-in. Vite's default + * browser-external Proxy stub returns undefined for all property accesses, so + * `(0, L.channel)("lru-cache:metrics")` throws at module initialisation time. + * This stub provides no-op implementations that satisfy lru-cache's usage. + */ +export const diagnosticsChannelStub: EsbuildPlugin = { + name: "stub-node-diagnostics-channel", + setup(build) { + build.onResolve({ filter: /^node:diagnostics_channel$/ }, () => ({ + path: "node-diagnostics-channel-stub", + namespace: "diagnostics-channel-stub", + })); + build.onLoad({ filter: /.*/, namespace: "diagnostics-channel-stub" }, () => ({ + contents: ` + const noop = () => {}; + const noopChannel = () => ({ + hasSubscribers: false, + subscribe: noop, + unsubscribe: noop, + publish: () => false, + bindStore: noop, + unbindStore: noop, + }); + module.exports = { + channel: noopChannel, + tracingChannel: noopChannel, + hasSubscribers: () => false, + subscribe: noop, + unsubscribe: noop, + }; + `, + loader: "js", + })); + }, +}; + +/** + * Pre-bundles Comunica with esbuild when building the web worker. + * + * Vite's Rollup worker bundler generates broken require_libXXX stubs for + * Comunica's circular CJS deps. esbuild handles them correctly (same as + * InProcessWorker tests). This plugin intercepts the Comunica import inside + * the worker bundle and substitutes an esbuild-compiled flat ESM module. + */ +export function workerComunicaPlugin(): Plugin { + let prebundled: string | null = null; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + + return { + name: "worker-comunica-prebundle", + + async buildStart() { + if (prebundled) return; + const result = await build({ + stdin: { + contents: `export { QueryEngine } from "${COMUNICA_PKG}";`, + resolveDir: __dirname, + loader: "ts", + }, + bundle: true, + platform: "browser", + format: "esm", + target: "es2020", + write: false, + logLevel: "silent", + define: { + global: "globalThis", + }, + plugins: [diagnosticsChannelStub], + }); + prebundled = result.outputFiles[0].text; + }, + + resolveId(id: string) { + if (id === COMUNICA_PKG) return VIRTUAL_ID; + }, + + load(id: string) { + if (id === VIRTUAL_ID) return prebundled ?? ""; + }, + }; +} diff --git a/vite.config.ts b/vite.config.ts index 536cd39e..b99633f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import tailwind from "@tailwindcss/vite"; import path from "path"; import { mcpManifestPlugin } from './vite-plugin-mcp-manifest'; import { bookmarkletPlugin } from './vite-plugin-bookmarklet'; +import { workerComunicaPlugin, diagnosticsChannelStub } from './vite-plugin-worker-comunica'; export default defineConfig({ @@ -25,8 +26,12 @@ export default defineConfig({ // Ensure worker bundles use ES modules output so Rollup can code-split worker chunks. // Default 'iife' will fail when code-splitting; explicitly set 'es' for modern browsers. + // Comunica v5 has circular CJS deps that Rollup's require_libXXX stubs can't + // reconstruct. The workerComunicaPlugin intercepts the import and substitutes + // an esbuild-compiled flat ESM bundle — same strategy as InProcessWorker tests. worker: { format: "es", + plugins: () => [workerComunicaPlugin()], }, resolve: { @@ -35,6 +40,23 @@ export default defineConfig({ }, }, + // Comunica's dependency chain has two browser-incompatible patterns that must be + // fixed in the dep optimizer pre-bundle: + // 1. node:diagnostics_channel (used by lru-cache): Vite's Proxy stub returns undefined + // for all property accesses → "(0, L.channel) is not a function" at init time. + // 2. `global` identifier (used by promise-polyfill): not defined in browser workers; + // define → globalThis so promise-polyfill resolves its root object correctly. + // This also prevents a cascade failure where promise-polyfill's broken __commonJS + // wrapper caches an empty exports object, breaking downstream class inheritance. + optimizeDeps: { + esbuildOptions: { + define: { + global: "globalThis", + }, + plugins: [diagnosticsChannelStub], + }, + }, + // Focused production build config: only what's necessary to produce self-contained worker bundles build: { },