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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions opendia-extension/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion opendia-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 1 addition & 1 deletion opendia-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
134 changes: 134 additions & 0 deletions opendia-mcp/test-connection.js
Original file line number Diff line number Diff line change
@@ -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);
});
68 changes: 68 additions & 0 deletions opendia-mcp/test-helpers.js
Original file line number Diff line number Diff line change
@@ -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 };
123 changes: 123 additions & 0 deletions opendia-mcp/test-protocol.js
Original file line number Diff line number Diff line change
@@ -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);
});