mirror of
https://github.com/aaronjmars/opendia.git
synced 2026-10-19 02:09:10 +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
|
- run: npm ci
|
||||||
- name: Syntax-check server
|
- name: Syntax-check server
|
||||||
run: node --check server.js
|
run: node --check server.js
|
||||||
|
- name: Protocol tests
|
||||||
|
run: node test-protocol.js
|
||||||
|
- name: Connection tests
|
||||||
|
run: node test-connection.js
|
||||||
|
|
||||||
extension:
|
extension:
|
||||||
name: Browser 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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz",
|
||||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -3289,9 +3289,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shell-quote": {
|
"node_modules/shell-quote": {
|
||||||
"version": "1.8.4",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
|
||||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@
|
||||||
"web-ext": "^10.5.0"
|
"web-ext": "^10.5.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"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",
|
"tunnel": "node server.js --tunnel",
|
||||||
"sse-only": "node server.js --sse-only",
|
"sse-only": "node server.js --sse-only",
|
||||||
"tunnel-sse": "node server.js --tunnel --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": [
|
"keywords": [
|
||||||
"mcp",
|
"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