diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4deaa26..bc269f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,10 @@ jobs: - run: npm ci - name: Syntax-check server run: node --check server.js + - name: Protocol tests + run: node test-protocol.js + - name: Connection tests + run: node test-connection.js extension: name: Browser extension diff --git a/opendia-extension/package-lock.json b/opendia-extension/package-lock.json index eec51d5..c6cd961 100644 --- a/opendia-extension/package-lock.json +++ b/opendia-extension/package-lock.json @@ -789,9 +789,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", + "integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", "dev": true, "license": "MIT", "dependencies": { @@ -3289,9 +3289,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { diff --git a/opendia-extension/package.json b/opendia-extension/package.json index 7029a0c..5f89db1 100644 --- a/opendia-extension/package.json +++ b/opendia-extension/package.json @@ -26,6 +26,8 @@ "web-ext": "^10.5.0" }, "overrides": { - "adm-zip": "^0.6.0" + "adm-zip": "^0.6.0", + "brace-expansion": "^1.1.16", + "shell-quote": "^1.9.0" } } diff --git a/opendia-mcp/package.json b/opendia-mcp/package.json index d4f8ed3..736af4a 100644 --- a/opendia-mcp/package.json +++ b/opendia-mcp/package.json @@ -11,7 +11,7 @@ "tunnel": "node server.js --tunnel", "sse-only": "node server.js --sse-only", "tunnel-sse": "node server.js --tunnel --sse-only", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --check server.js && node test-protocol.js && node test-connection.js" }, "keywords": [ "mcp", diff --git a/opendia-mcp/test-connection.js b/opendia-mcp/test-connection.js new file mode 100644 index 0000000..f683d44 --- /dev/null +++ b/opendia-mcp/test-connection.js @@ -0,0 +1,134 @@ +// Runtime tests for the extension's ConnectionManager, driven against the real +// MCP server. Lives here rather than in opendia-extension because it needs both +// the server and the `ws` client, which are this package's dependencies; it +// reads the extension source directly and never imports from that package. +// +// Simulates Firefox MV2 (importScripts undefined -> isServiceWorker false, a +// manifest with applications.gecko -> isFirefox true). That is the path which +// installs a heartbeat and schedules reconnects, so it is where a stale socket +// event does the most damage. +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const WebSocket = require('ws'); +const { sleep, makeAsserter, startServer } = require('./test-helpers'); + +const BACKGROUND = path.join(__dirname, '..', 'opendia-extension', 'src', 'background', 'background.js'); +const WS_PORT = 45553; +const HTTP_PORT = 45554; + +const { assert, state } = makeAsserter(); + +// Loads the real background.js under stubbed extension globals. +function loadBackground(wsPort, httpPort) { + const manifest = { manifest_version: 2, applications: { gecko: { id: 'opendia@test' } } }; + const sandbox = { + console: { log: () => {}, error: () => {}, warn: () => {} }, + setTimeout, clearTimeout, setInterval, clearInterval, + WebSocket, Date, JSON, Promise, Error, Math, Array, Object, String, Number, Boolean, + performance: { now: () => Date.now() }, + // discoverServerPorts probes a fixed port list that will not contain our + // test port, so answer the first probe and point it at the real server. + fetch: async (url) => { + if (!url.includes(':5556/ports')) throw new Error('connection refused'); + return { + ok: true, + json: async () => ({ + websocket: wsPort, + http: httpPort, + websocketUrl: `ws://127.0.0.1:${wsPort}`, + httpUrl: `http://127.0.0.1:${httpPort}`, + }), + }; + }, + chrome: undefined, + }; + sandbox.globalThis = sandbox; + sandbox.browser = { + runtime: { getManifest: () => manifest, onMessage: { addListener: () => {} }, lastError: null }, + storage: { local: { get: (_keys, cb) => cb({}), set: () => {} } }, + tabs: { query: async () => [], get: async () => ({}), sendMessage: () => {} }, + }; + + const ctx = vm.createContext(sandbox); + // Top-level const/let stay lexical in a vm script, so export what we drive. + const src = fs.readFileSync(BACKGROUND, 'utf8') + '\n;globalThis.__cm = connectionManager;'; + vm.runInContext(src, ctx, { filename: 'background.js' }); + return ctx.__cm; +} + +async function run() { + const { proc: srv, wsPort, httpPort } = await startServer({ wsPort: WS_PORT, httpPort: HTTP_PORT }); + srv.stdout.on('data', () => {}); + + try { + assert('server listening', wsPort > 0 && httpPort > 0, `ws=${wsPort} http=${httpPort}`); + + const cm = loadBackground(wsPort, httpPort); + assert('background.js loaded, ConnectionManager present', !!cm); + assert('simulating Firefox MV2 (heartbeat path)', + cm.isServiceWorker === false && !!cm.isFirefox); + + // --- A stale socket's events must not touch live connection state --- + await cm.connect(); + await sleep(400); + const socketA = cm.mcpSocket; + assert('connected, socket A open', socketA && socketA.readyState === WebSocket.OPEN); + assert('heartbeat installed', cm.heartbeatInterval !== null); + assert('attempts reset on successful open', cm.reconnectAttempts === 0); + + // Open a second connection. The server closes the previous extension socket + // when a new one arrives, so A's close event lands while B is the live one. + await cm.createConnection(); + await sleep(800); + const socketB = cm.mcpSocket; + assert('socket replaced, B is current', socketB !== socketA); + assert('socket A closed by the server', socketA.readyState === WebSocket.CLOSED); + assert('socket B still open', socketB.readyState === WebSocket.OPEN); + + // The three things a stale close used to do to a healthy connection: + assert('stale close did not clear the live heartbeat', cm.heartbeatInterval !== null); + assert('stale close did not inflate the attempt counter', cm.reconnectAttempts === 0, + `attempts=${cm.reconnectAttempts}`); + assert('stale close did not arm a reconnect', cm.reconnectTimer === null, + cm.reconnectTimer === null ? '' : 'ARMED'); + + // --- One attempt increment per real failure, not two --- + srv.kill(); + await sleep(600); + cm.clearHeartbeat(); + cm.clearReconnectTimer(); + cm.reconnectAttempts = 0; + cm.mcpSocket = null; + + try { await cm.createConnection(); } catch { /* expected */ } + await sleep(900); + assert('a failed connect increments attempts exactly once', cm.reconnectAttempts === 1, + `attempts=${cm.reconnectAttempts}; 2 means onerror and onclose are both counting`); + + // --- Reconnect must be one-shot, not fixed-rate --- + cm.clearReconnectTimer(); + cm.reconnectAttempts = 0; + cm.scheduleReconnect(); + assert('reconnect scheduled', cm.reconnectTimer !== null); + // Node marks a repeating timer with a non-null _repeat. + assert('scheduled one-shot (setTimeout, not setInterval)', + cm.reconnectTimer && cm.reconnectTimer._repeat === null, + `_repeat=${cm.reconnectTimer && cm.reconnectTimer._repeat}`); + cm.clearReconnectTimer(); + assert('clearReconnectTimer disarms it', cm.reconnectTimer === null); + } finally { + srv.kill(); + await sleep(200); + } +} + +run() + .then(() => { + console.log(state.failures === 0 ? '\nāœ… Connection tests passed' : `\nāŒ ${state.failures} failure(s)`); + process.exit(state.failures === 0 ? 0 : 1); + }) + .catch((e) => { + console.error('āŒ Harness error:', e.message); + process.exit(1); + }); diff --git a/opendia-mcp/test-helpers.js b/opendia-mcp/test-helpers.js new file mode 100644 index 0000000..664afa2 --- /dev/null +++ b/opendia-mcp/test-helpers.js @@ -0,0 +1,68 @@ +// Shared harness helpers for the runtime test suites. +const { spawn } = require('child_process'); +const path = require('path'); + +const SERVER = path.join(__dirname, 'server.js'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function makeAsserter() { + const state = { failures: 0 }; + const assert = (label, cond, detail = '') => { + console.log(`${cond ? ' ok ' : ' FAIL '} ${label}${detail ? ' -> ' + detail : ''}`); + if (!cond) state.failures++; + }; + return { assert, state }; +} + +// Starts the server and resolves once it reports it is listening. +// +// Reads the ports back from the startup banner rather than assuming the +// requested ones were free: the server shifts to the next available port on a +// conflict, which would otherwise leave the test polling an address nothing is +// bound to. On failure the captured stderr is included, so a CI failure is +// diagnosable from the log alone. +function startServer({ wsPort, httpPort, timeoutMs = 30000 } = {}) { + const proc = spawn('node', [SERVER, `--ws-port=${wsPort}`, `--http-port=${httpPort}`], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stderr = ''; + let exited = null; + proc.stderr.on('data', (c) => { stderr += c.toString(); }); + proc.on('exit', (code, signal) => { exited = { code, signal }; }); + + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + const fail = (why) => { + try { proc.kill(); } catch { /* already gone */ } + reject(new Error( + `${why}\n--- server stderr ---\n${stderr.trim() || '(no output)'}\n---------------------` + )); + }; + + const poll = () => { + if (exited) { + return fail(`server exited early (code=${exited.code} signal=${exited.signal})`); + } + // "🌐 HTTP/SSE server running on 127.0.0.1:5556" + const listening = stderr.match(/HTTP\/SSE server running on\s+(\S+):(\d+)/); + // "āœ… Ports resolved: WebSocket=5555, HTTP=5556" + const resolved = stderr.match(/Ports resolved:\s*WebSocket=(\d+),\s*HTTP=(\d+)/); + if (listening && resolved) { + return resolve({ + proc, + host: listening[1], + wsPort: Number(resolved[1]), + httpPort: Number(resolved[2]), + stderr: () => stderr, + }); + } + if (Date.now() > deadline) return fail(`server did not report listening within ${timeoutMs}ms`); + setTimeout(poll, 150); + }; + poll(); + }); +} + +module.exports = { sleep, makeAsserter, startServer }; diff --git a/opendia-mcp/test-protocol.js b/opendia-mcp/test-protocol.js new file mode 100644 index 0000000..8384e1a --- /dev/null +++ b/opendia-mcp/test-protocol.js @@ -0,0 +1,123 @@ +// Protocol tests: drives the real server over stdio and SSE. +// +// Covers notifications/tools/list_changed, which exists because the extension +// connects independently of the MCP client: a client that lists tools before +// the extension is up gets fallback schemas, and without this notification it +// would keep them for the whole session. +const http = require('http'); +const WebSocket = require('ws'); +const { sleep, makeAsserter, startServer } = require('./test-helpers'); + +const WS_PORT = 45551; +const HTTP_PORT = 45552; + +const { assert, state } = makeAsserter(); + +async function run() { + const { proc: srv, httpPort, wsPort } = await startServer({ wsPort: WS_PORT, httpPort: HTTP_PORT }); + + const frames = []; + let buf = ''; + srv.stdout.on('data', (c) => { + buf += c.toString(); + const lines = buf.split('\n'); + buf = lines.pop() || ''; + for (const l of lines) { + if (!l.trim()) continue; + try { frames.push(JSON.parse(l)); } catch { /* non-JSON */ } + } + }); + + const send = (o) => srv.stdin.write(JSON.stringify(o) + '\n'); + const notes = () => frames.filter((f) => f.method === 'notifications/tools/list_changed'); + const resultFor = (id) => frames.find((f) => f.id === id)?.result; + + try { + assert('server listening', wsPort > 0 && httpPort > 0, `ws=${wsPort} http=${httpPort}`); + + // --- initialize --- + send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: 'test' } } }); + await sleep(300); + assert('initialize declares tools.listChanged', + resultFor(1)?.capabilities?.tools?.listChanged === true); + + // --- cold tools/list, before the extension connects --- + send({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + await sleep(300); + const before = resultFor(2)?.tools || []; + assert('cold tools/list returns fallback tools', before.length > 0, `${before.length} tools`); + assert('no notification before anything changed', notes().length === 0); + + // --- SSE stream, opened before the change so it can receive the push --- + const sseFrames = []; + await new Promise((resolve, reject) => { + const req = http.get( + { host: '127.0.0.1', port: httpPort, path: '/sse', headers: { Accept: 'text/event-stream' } }, + (res) => { + assert('SSE stream opened', res.statusCode === 200, `HTTP ${res.statusCode}`); + res.on('data', (c) => { + for (const block of c.toString().split('\n\n')) { + const line = block.trim(); + if (!line.startsWith('data: ')) continue; + try { sseFrames.push(JSON.parse(line.slice(6))); } catch { /* ignore */ } + } + }); + resolve(); + } + ); + req.on('error', reject); + }); + await sleep(300); + + // --- a fake extension registers its tools --- + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`); + await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej); }); + const tools = [ + { name: 'page_analyze', description: 'x', inputSchema: { type: 'object', properties: { tab_id: { type: 'number' } } } }, + { name: 'element_click', description: 'x', inputSchema: { type: 'object', properties: { tab_id: { type: 'number' } } } }, + ]; + ws.send(JSON.stringify({ type: 'register', tools })); + await sleep(600); + + assert('notification sent after extension registers', notes().length === 1, + `${notes().length} sent`); + const n = notes()[0]; + assert('notification is a well-formed JSON-RPC notification', + n && n.jsonrpc === '2.0' && !('id' in n), JSON.stringify(n)); + assert('notification also delivered over SSE', + sseFrames.filter((f) => f.method === 'notifications/tools/list_changed').length === 1); + + // --- re-list now returns the extension's schemas --- + send({ jsonrpc: '2.0', id: 3, method: 'tools/list' }); + await sleep(300); + const after = resultFor(3)?.tools || []; + assert('re-list returns extension tools', after.length === 2, `${after.length} tools`); + assert('extension schemas carry tab_id', + after.every((t) => t.inputSchema?.properties?.tab_id)); + + // --- an unchanged re-register must not re-notify --- + ws.send(JSON.stringify({ type: 'register', tools })); + await sleep(400); + assert('identical re-register does not re-notify', notes().length === 1, + `${notes().length} total`); + + // --- disconnect reverts the list, so notify again --- + ws.close(); + await sleep(700); + assert('notification sent on extension disconnect', notes().length === 2, + `${notes().length} total`); + } finally { + srv.kill(); + await sleep(200); + } +} + +run() + .then(() => { + console.log(state.failures === 0 ? '\nāœ… Protocol tests passed' : `\nāŒ ${state.failures} failure(s)`); + process.exit(state.failures === 0 ? 0 : 1); + }) + .catch((e) => { + console.error('āŒ Harness error:', e.message); + process.exit(1); + });