From 0ce84ae823d459b10c6a0bc199efdd7c6f007439 Mon Sep 17 00:00:00 2001 From: Aaron Elijah Mars <61592645+aaronjmars@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:23:03 -0400 Subject: [PATCH 1/2] chore: add runtime tests to CI, override two vulnerable dev transitives Runtime tests. CI was structural only - it greps built files and never executes a tool call - so the connection-layer and protocol work merged in #63 had no automated coverage. Two suites now run in the MCP job: test-protocol.js drives the real server over stdio and SSE: cold tools/list returns fallback schemas, a fake extension registers over WebSocket, exactly one well-formed tools/list_changed arrives on both transports, re-list returns the extension's schemas, an unchanged re-register stays silent, and a disconnect notifies again. test-connection.js loads the real background.js under stubbed extension globals (simulating Firefox MV2, the heartbeat path) and drives it against the real server: a replaced socket's close event must not clear the live connection's heartbeat, inflate the attempt counter, or arm a reconnect; a failed connect must increment attempts once, not twice; and the reconnect must be one-shot rather than fixed-rate. Both were validated against the pre-fix code at aefab6a, where the connection suite fails 5 of its assertions - so these detect the regressions they describe rather than passing vacuously. They live in opendia-mcp because they need the server and the ws client, which are that package's dependencies; test-connection.js reads the extension source by path and does not import from that package. Neither ships to npm - the package's files list is server.js and README.md. npm test is now these suites plus the syntax check, replacing a stub that always exited 1. Dependency overrides. Two high-severity Dependabot alerts, both development scope and both reached only through web-ext: brace-expansion 1.1.15 -> 1.1.17 (via multimatch -> minimatch), GHSA-3jxr-9vmj-r5cp shell-quote 1.8.4 -> 1.10.0 (via fx-runner), GHSA-395f-4hp3-45gv Both are denial-of-service by input complexity in build tooling. Nothing reaches a user: web-ext is a devDependency and node_modules is not part of the extension bundle. Overrides because web-ext pins the vulnerable ranges transitively. web-ext lint still reports 0 errors and the same 3 pre-existing warnings. --- .github/workflows/ci.yml | 4 + opendia-extension/package-lock.json | 12 +- opendia-extension/package.json | 4 +- opendia-mcp/package.json | 2 +- opendia-mcp/test-connection.js | 173 ++++++++++++++++++++++++++++ opendia-mcp/test-protocol.js | 165 ++++++++++++++++++++++++++ 6 files changed, 352 insertions(+), 8 deletions(-) create mode 100644 opendia-mcp/test-connection.js create mode 100644 opendia-mcp/test-protocol.js 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..0f10529 --- /dev/null +++ b/opendia-mcp/test-connection.js @@ -0,0 +1,173 @@ +// 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 { spawn } = require('child_process'); +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +const vm = require('vm'); +const WebSocket = require('ws'); + +const SERVER = path.join(__dirname, 'server.js'); +const BACKGROUND = path.join(__dirname, '..', 'opendia-extension', 'src', 'background', 'background.js'); +const WS_PORT = 45553; +const HTTP_PORT = 45554; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +let failures = 0; +function assert(label, cond, detail = '') { + console.log(`${cond ? ' ok ' : ' FAIL '} ${label}${detail ? ' -> ' + detail : ''}`); + if (!cond) failures++; +} + +function waitForServer(timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const attempt = () => { + const req = http.get( + { host: '127.0.0.1', port: HTTP_PORT, path: '/ports', timeout: 1000 }, + (res) => { + let body = ''; + res.on('data', (c) => (body += c)); + res.on('end', () => { + try { resolve(JSON.parse(body)); } catch (e) { reject(e); } + }); + } + ); + req.on('error', () => { + if (Date.now() > deadline) { + reject(new Error(`server never came up on ${HTTP_PORT} (port busy?)`)); + } else { + setTimeout(attempt, 250); + } + }); + req.on('timeout', () => req.destroy()); + }; + attempt(); + }); +} + +// Loads the real background.js under stubbed extension globals. +function loadBackground() { + 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: WS_PORT, + http: HTTP_PORT, + websocketUrl: `ws://127.0.0.1:${WS_PORT}`, + httpUrl: `http://127.0.0.1:${HTTP_PORT}`, + }), + }; + }, + 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 srv = spawn('node', [SERVER, `--ws-port=${WS_PORT}`, `--http-port=${HTTP_PORT}`], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + srv.stdout.on('data', () => {}); + srv.stderr.on('data', () => {}); + + try { + const ports = await waitForServer(); + assert('server listening on requested ports', ports.websocket === WS_PORT, `ws=${ports.websocket}`); + + const cm = loadBackground(); + 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(failures === 0 ? '\n✅ Connection tests passed' : `\n❌ ${failures} failure(s)`); + process.exit(failures === 0 ? 0 : 1); + }) + .catch((e) => { + console.error('❌ Harness error:', e.message); + process.exit(1); + }); diff --git a/opendia-mcp/test-protocol.js b/opendia-mcp/test-protocol.js new file mode 100644 index 0000000..5074db5 --- /dev/null +++ b/opendia-mcp/test-protocol.js @@ -0,0 +1,165 @@ +// 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 { spawn } = require('child_process'); +const http = require('http'); +const path = require('path'); +const WebSocket = require('ws'); + +const SERVER = path.join(__dirname, 'server.js'); +const WS_PORT = 45551; +const HTTP_PORT = 45552; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +let failures = 0; +function assert(label, cond, detail = '') { + console.log(`${cond ? ' ok ' : ' FAIL '} ${label}${detail ? ' -> ' + detail : ''}`); + if (!cond) failures++; +} + +// The server shifts to another port when one is busy, which would silently +// point the rest of the test at nothing. Confirm it took the ports we asked for. +function waitForServer(timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const attempt = () => { + const req = http.get( + { host: '127.0.0.1', port: HTTP_PORT, path: '/ports', timeout: 1000 }, + (res) => { + let body = ''; + res.on('data', (c) => (body += c)); + res.on('end', () => { + try { resolve(JSON.parse(body)); } catch (e) { reject(e); } + }); + } + ); + req.on('error', () => { + if (Date.now() > deadline) { + reject(new Error(`server never came up on ${HTTP_PORT} (port busy?)`)); + } else { + setTimeout(attempt, 250); + } + }); + req.on('timeout', () => req.destroy()); + }; + attempt(); + }); +} + +async function run() { + const srv = spawn('node', [SERVER, `--ws-port=${WS_PORT}`, `--http-port=${HTTP_PORT}`], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + srv.stderr.on('data', () => {}); + + 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 { + const ports = await waitForServer(); + assert('server listening on requested ports', ports.websocket === WS_PORT, + `ws=${ports.websocket}`); + + // --- 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: HTTP_PORT, 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:${WS_PORT}`); + 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(failures === 0 ? '\n✅ Protocol tests passed' : `\n❌ ${failures} failure(s)`); + process.exit(failures === 0 ? 0 : 1); + }) + .catch((e) => { + console.error('❌ Harness error:', e.message); + process.exit(1); + }); From f932e31d5e51f3ee981a1ad09dc6e4ab78e84f22 Mon Sep 17 00:00:00 2001 From: Aaron Elijah Mars <61592645+aaronjmars@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:27:48 -0400 Subject: [PATCH 2/2] test: read real ports from the server banner and surface stderr on failure CI failed with 'server never came up on 45554 (port busy?)' while both suites passed locally on Node 20 and 26. The harness could not say why: it swallowed the server's stderr, polled only the port it had requested, and never noticed if the process had exited. Both suites now share test-helpers.js, which waits for the server's own 'HTTP/SSE server running' banner, parses the actual ports out of 'Ports resolved: WebSocket=..., HTTP=...', detects an early exit, and includes the captured stderr in the thrown error. The server shifts to the next free port on a conflict, so assuming the requested port was a latent bug in the test rather than a property of the server. Re-verified against the pre-fix code at aefab6a: the connection suite still fails 5 assertions there, so the refactor did not weaken detection. --- opendia-mcp/test-connection.js | 63 ++++++------------------------- opendia-mcp/test-helpers.js | 68 ++++++++++++++++++++++++++++++++++ opendia-mcp/test-protocol.js | 58 ++++------------------------- 3 files changed, 88 insertions(+), 101 deletions(-) create mode 100644 opendia-mcp/test-helpers.js diff --git a/opendia-mcp/test-connection.js b/opendia-mcp/test-connection.js index 0f10529..f683d44 100644 --- a/opendia-mcp/test-connection.js +++ b/opendia-mcp/test-connection.js @@ -7,55 +7,20 @@ // 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 { spawn } = require('child_process'); const fs = require('fs'); -const http = require('http'); const path = require('path'); const vm = require('vm'); const WebSocket = require('ws'); +const { sleep, makeAsserter, startServer } = require('./test-helpers'); -const SERVER = path.join(__dirname, 'server.js'); const BACKGROUND = path.join(__dirname, '..', 'opendia-extension', 'src', 'background', 'background.js'); const WS_PORT = 45553; const HTTP_PORT = 45554; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -let failures = 0; -function assert(label, cond, detail = '') { - console.log(`${cond ? ' ok ' : ' FAIL '} ${label}${detail ? ' -> ' + detail : ''}`); - if (!cond) failures++; -} - -function waitForServer(timeoutMs = 10000) { - const deadline = Date.now() + timeoutMs; - return new Promise((resolve, reject) => { - const attempt = () => { - const req = http.get( - { host: '127.0.0.1', port: HTTP_PORT, path: '/ports', timeout: 1000 }, - (res) => { - let body = ''; - res.on('data', (c) => (body += c)); - res.on('end', () => { - try { resolve(JSON.parse(body)); } catch (e) { reject(e); } - }); - } - ); - req.on('error', () => { - if (Date.now() > deadline) { - reject(new Error(`server never came up on ${HTTP_PORT} (port busy?)`)); - } else { - setTimeout(attempt, 250); - } - }); - req.on('timeout', () => req.destroy()); - }; - attempt(); - }); -} +const { assert, state } = makeAsserter(); // Loads the real background.js under stubbed extension globals. -function loadBackground() { +function loadBackground(wsPort, httpPort) { const manifest = { manifest_version: 2, applications: { gecko: { id: 'opendia@test' } } }; const sandbox = { console: { log: () => {}, error: () => {}, warn: () => {} }, @@ -69,10 +34,10 @@ function loadBackground() { return { ok: true, json: async () => ({ - websocket: WS_PORT, - http: HTTP_PORT, - websocketUrl: `ws://127.0.0.1:${WS_PORT}`, - httpUrl: `http://127.0.0.1:${HTTP_PORT}`, + websocket: wsPort, + http: httpPort, + websocketUrl: `ws://127.0.0.1:${wsPort}`, + httpUrl: `http://127.0.0.1:${httpPort}`, }), }; }, @@ -93,17 +58,13 @@ function loadBackground() { } async function run() { - const srv = spawn('node', [SERVER, `--ws-port=${WS_PORT}`, `--http-port=${HTTP_PORT}`], { - stdio: ['pipe', 'pipe', 'pipe'], - }); + const { proc: srv, wsPort, httpPort } = await startServer({ wsPort: WS_PORT, httpPort: HTTP_PORT }); srv.stdout.on('data', () => {}); - srv.stderr.on('data', () => {}); try { - const ports = await waitForServer(); - assert('server listening on requested ports', ports.websocket === WS_PORT, `ws=${ports.websocket}`); + assert('server listening', wsPort > 0 && httpPort > 0, `ws=${wsPort} http=${httpPort}`); - const cm = loadBackground(); + const cm = loadBackground(wsPort, httpPort); assert('background.js loaded, ConnectionManager present', !!cm); assert('simulating Firefox MV2 (heartbeat path)', cm.isServiceWorker === false && !!cm.isFirefox); @@ -164,8 +125,8 @@ async function run() { run() .then(() => { - console.log(failures === 0 ? '\n✅ Connection tests passed' : `\n❌ ${failures} failure(s)`); - process.exit(failures === 0 ? 0 : 1); + 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); 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 index 5074db5..8384e1a 100644 --- a/opendia-mcp/test-protocol.js +++ b/opendia-mcp/test-protocol.js @@ -4,57 +4,17 @@ // 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 { spawn } = require('child_process'); const http = require('http'); -const path = require('path'); const WebSocket = require('ws'); +const { sleep, makeAsserter, startServer } = require('./test-helpers'); -const SERVER = path.join(__dirname, 'server.js'); const WS_PORT = 45551; const HTTP_PORT = 45552; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -let failures = 0; -function assert(label, cond, detail = '') { - console.log(`${cond ? ' ok ' : ' FAIL '} ${label}${detail ? ' -> ' + detail : ''}`); - if (!cond) failures++; -} - -// The server shifts to another port when one is busy, which would silently -// point the rest of the test at nothing. Confirm it took the ports we asked for. -function waitForServer(timeoutMs = 10000) { - const deadline = Date.now() + timeoutMs; - return new Promise((resolve, reject) => { - const attempt = () => { - const req = http.get( - { host: '127.0.0.1', port: HTTP_PORT, path: '/ports', timeout: 1000 }, - (res) => { - let body = ''; - res.on('data', (c) => (body += c)); - res.on('end', () => { - try { resolve(JSON.parse(body)); } catch (e) { reject(e); } - }); - } - ); - req.on('error', () => { - if (Date.now() > deadline) { - reject(new Error(`server never came up on ${HTTP_PORT} (port busy?)`)); - } else { - setTimeout(attempt, 250); - } - }); - req.on('timeout', () => req.destroy()); - }; - attempt(); - }); -} +const { assert, state } = makeAsserter(); async function run() { - const srv = spawn('node', [SERVER, `--ws-port=${WS_PORT}`, `--http-port=${HTTP_PORT}`], { - stdio: ['pipe', 'pipe', 'pipe'], - }); - srv.stderr.on('data', () => {}); + const { proc: srv, httpPort, wsPort } = await startServer({ wsPort: WS_PORT, httpPort: HTTP_PORT }); const frames = []; let buf = ''; @@ -73,9 +33,7 @@ async function run() { const resultFor = (id) => frames.find((f) => f.id === id)?.result; try { - const ports = await waitForServer(); - assert('server listening on requested ports', ports.websocket === WS_PORT, - `ws=${ports.websocket}`); + assert('server listening', wsPort > 0 && httpPort > 0, `ws=${wsPort} http=${httpPort}`); // --- initialize --- send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: 'test' } } }); @@ -94,7 +52,7 @@ async function run() { const sseFrames = []; await new Promise((resolve, reject) => { const req = http.get( - { host: '127.0.0.1', port: HTTP_PORT, path: '/sse', headers: { Accept: 'text/event-stream' } }, + { 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) => { @@ -112,7 +70,7 @@ async function run() { await sleep(300); // --- a fake extension registers its tools --- - const ws = new WebSocket(`ws://127.0.0.1:${WS_PORT}`); + 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' } } } }, @@ -156,8 +114,8 @@ async function run() { run() .then(() => { - console.log(failures === 0 ? '\n✅ Protocol tests passed' : `\n❌ ${failures} failure(s)`); - process.exit(failures === 0 ? 0 : 1); + 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);