chore: runtime tests in CI + override two vulnerable dev transitives (#64)
Some checks are pending
CI / MCP server (push) Waiting to run
CI / Browser extension (push) Waiting to run

Adds two runtime suites to the MCP job: protocol tests driving the real server
over stdio and SSE, and connection tests loading the real background.js against
it. Both were validated against the pre-fix code at aefab6a, where the
connection suite fails 5 assertions.

Overrides brace-expansion and shell-quote past two high-severity advisories.
Both are development scope, reached only through web-ext, and neither is part
of the extension bundle.
This commit is contained in:
@aaronjmars 2026-07-29 15:28:51 -04:00 • committed by GitHub
commit 8396f7bb2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 339 additions and 8 deletions

View file

@ -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

View file

@ -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": {

View file

@ -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"
}
}

View file

@ -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",

View file

@ -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);
});

View file

@ -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 };

View file

@ -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);
});