mirror of
https://github.com/aaronjmars/opendia.git
synced 2026-10-15 00:19:55 +00:00
chore: runtime tests in CI + override two vulnerable dev transitives (#64)
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:
commit
8396f7bb2c
7 changed files with 339 additions and 8 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
12
opendia-extension/package-lock.json
generated
12
opendia-extension/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
134
opendia-mcp/test-connection.js
Normal file
134
opendia-mcp/test-connection.js
Normal 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);
|
||||
});
|
||||
68
opendia-mcp/test-helpers.js
Normal file
68
opendia-mcp/test-helpers.js
Normal 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 };
|
||||
123
opendia-mcp/test-protocol.js
Normal file
123
opendia-mcp/test-protocol.js
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue