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
107 changes: 107 additions & 0 deletions e2e/sparql-worker.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 { <urn:sparql-test:s> <urn:sparql-test:p> <urn:sparql-test:o> }",
);
expect(result.success).toBe(true);
expect(result.data.updated).toBe(true);
});

test("SELECT returns inserted triple", async ({ page }) => {
await qg(
page,
"INSERT DATA { <urn:sparql-test:s> <urn:sparql-test:p> <urn:sparql-test:o> }",
);
const result = await qg(
page,
"SELECT ?s ?p ?o WHERE { <urn:sparql-test:s> ?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 { <urn:sparql-test:s> <urn:sparql-test:p> <urn:sparql-test:o> }",
);
const result = await qg(
page,
"CONSTRUCT { ?s <urn:sparql-test:p> ?o } WHERE { ?s <urn:sparql-test:p> ?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 { <urn:sparql-test:del> <urn:sparql-test:p> <urn:sparql-test:o> }",
);
await qg(
page,
"DELETE DATA { <urn:sparql-test:del> <urn:sparql-test:p> <urn:sparql-test:o> }",
);
const result = await qg(
page,
"SELECT ?o WHERE { <urn:sparql-test:del> <urn:sparql-test:p> ?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);
});
});
93 changes: 93 additions & 0 deletions vite-plugin-worker-comunica.ts
Original file line number Diff line number Diff line change
@@ -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 ?? "";
},
};
}
22 changes: 22 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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: {
Expand All @@ -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: {
},
Expand Down
Loading