Skip to content

Commit f3039cc

Browse files
committed
feat: enhance client tests with additional scenarios for sendBeacon and batch processing, and update route tests for improved error handling and logging behavior
1 parent 71f1e39 commit f3039cc

13 files changed

Lines changed: 628 additions & 180 deletions

.browser-echo-mcp.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"url":"http://127.0.0.1:5179","routeLogs":"/__client-logs","timestamp":1755797556236,"pid":33695}
1+
{"url":"http://127.0.0.1:5179","routeLogs":"/__client-logs","timestamp":1755811764407,"pid":28310}

packages/core/test/client.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { initBrowserEcho } from '../src/client';
3+
import * as coreIndex from '../src/index';
34

45
describe('@browser-echo/core', () => {
56
beforeEach(() => {
@@ -36,4 +37,73 @@ describe('@browser-echo/core', () => {
3637
(navigator as any).sendBeacon = originalSendBeacon;
3738
}
3839
});
40+
41+
it('uses sendBeacon when available', async () => {
42+
const originalSendBeacon = (navigator as any).sendBeacon;
43+
const sendBeaconSpy = vi.fn().mockReturnValue(true);
44+
(navigator as any).sendBeacon = sendBeaconSpy;
45+
46+
const fetchSpy = vi.spyOn(globalThis, 'fetch' as any).mockResolvedValue({} as any);
47+
48+
initBrowserEcho({ route: '/__client-logs', include: ['log'], preserveConsole: true });
49+
console.log('hello beacon');
50+
await new Promise(r => setTimeout(r, 350));
51+
52+
expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
53+
expect(fetchSpy).not.toHaveBeenCalled();
54+
55+
fetchSpy.mockRestore();
56+
(navigator as any).sendBeacon = originalSendBeacon;
57+
});
58+
59+
it('flushes immediately when batch size threshold is reached', async () => {
60+
const originalSendBeacon = (navigator as any).sendBeacon;
61+
try { delete (navigator as any).sendBeacon; } catch {}
62+
63+
const fetchSpy = vi.spyOn(globalThis, 'fetch' as any).mockResolvedValue({} as any);
64+
65+
initBrowserEcho({ route: '/__client-logs', include: ['log'], batch: { size: 2, interval: 1000 } });
66+
67+
console.log('a');
68+
console.log('b'); // reaching batch size triggers flush
69+
70+
await new Promise(r => setTimeout(r, 50));
71+
expect(fetchSpy).toHaveBeenCalledTimes(1);
72+
73+
fetchSpy.mockRestore();
74+
if (originalSendBeacon !== undefined) {
75+
(navigator as any).sendBeacon = originalSendBeacon;
76+
}
77+
});
78+
79+
it('does not call original console when preserveConsole is false', async () => {
80+
const originalSendBeacon = (navigator as any).sendBeacon;
81+
try { delete (navigator as any).sendBeacon; } catch {}
82+
const fetchSpy = vi.spyOn(globalThis, 'fetch' as any).mockResolvedValue({} as any);
83+
84+
// Install a stub as the original console.log to detect invocations
85+
const originalConsoleLog = console.log;
86+
let originalCalled = 0;
87+
// @ts-ignore
88+
console.log = function stub() { originalCalled++; } as any;
89+
90+
initBrowserEcho({ route: '/__client-logs', include: ['log'], preserveConsole: false });
91+
console.log('should not call original');
92+
93+
await new Promise(r => setTimeout(r, 50));
94+
expect(originalCalled).toBe(0);
95+
96+
// Cleanup
97+
console.log = originalConsoleLog;
98+
fetchSpy.mockRestore();
99+
if (originalSendBeacon !== undefined) {
100+
(navigator as any).sendBeacon = originalSendBeacon;
101+
}
102+
});
103+
104+
it('public API exports stay unchanged', () => {
105+
const keys = Object.keys(coreIndex as any).sort();
106+
expect(keys).toEqual(['initBrowserEcho']);
107+
expect(typeof (coreIndex as any).initBrowserEcho).toBe('function');
108+
});
39109
});

packages/mcp/test/e2e.next.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
2+
import { startMcpServer } from '../src/index';
3+
4+
const HOST = '127.0.0.1';
5+
const PORT = 5183;
6+
const BASE = `http://${HOST}:${PORT}`;
7+
8+
describe('E2E: Next route → MCP ingest', () => {
9+
const oldEnv = { ...process.env } as any;
10+
let POST: (req: any) => Promise<any>;
11+
beforeAll(async () => {
12+
await startMcpServer({ name: 'tests', version: 'test', host: HOST, port: PORT, endpoint: '/mcp', logsRoute: '/__client-logs' });
13+
process.env.BROWSER_ECHO_MCP_URL = BASE; // ensure route forwards
14+
// Import after setting env so the route reads the configured MCP URL
15+
const mod: any = await import('../../next/src/route');
16+
POST = mod.POST;
17+
}, 30_000);
18+
19+
afterAll(async () => {
20+
process.env = oldEnv;
21+
});
22+
23+
it('forwards an error log to MCP', async () => {
24+
const req: any = { json: async () => ({ sessionId: 'n3xte2e', entries: [{ level: 'error', text: 'next e2e err' }] }) };
25+
const res: any = await POST(req as any);
26+
expect((res as any).status).toBe(204);
27+
28+
await new Promise((r) => setTimeout(r, 200));
29+
const diag = await fetch(`${BASE}/__client-logs`);
30+
expect(diag.status).toBe(200);
31+
const body = await diag.text();
32+
expect(body).toContain('next e2e err');
33+
});
34+
35+
it('includes warn/info as well', async () => {
36+
const req: any = { json: async () => ({ sessionId: 'n3xtwi', entries: [
37+
{ level: 'warn', text: 'next e2e warn' },
38+
{ level: 'info', text: 'next e2e info' }
39+
] }) };
40+
const res: any = await POST(req as any);
41+
expect((res as any).status).toBe(204);
42+
43+
await new Promise((r) => setTimeout(r, 200));
44+
const diag = await fetch(`${BASE}/__client-logs`);
45+
const body = await diag.text();
46+
expect(body).toContain('next e2e warn');
47+
expect(body).toContain('next e2e info');
48+
});
49+
});
50+
51+

packages/mcp/test/e2e.nuxt.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
2+
import { startMcpServer } from '../src/index';
3+
import { createApp, createRouter, toNodeListener } from 'h3';
4+
import { createServer } from 'node:http';
5+
6+
const HOST = '127.0.0.1';
7+
const PORT = 5184;
8+
const BASE = `http://${HOST}:${PORT}`;
9+
10+
// Minimal h3 event mock compatible with our handler
11+
function makeEvent() { return {} as any; }
12+
13+
describe('E2E: Nuxt handler → MCP ingest', () => {
14+
const oldEnv = { ...process.env } as any;
15+
let handler: (e: any) => Promise<any>;
16+
beforeAll(async () => {
17+
await startMcpServer({ host: HOST, port: PORT, endpoint: '/mcp', logsRoute: '/__client-logs' });
18+
process.env.BROWSER_ECHO_MCP_URL = BASE;
19+
const mod: any = await import('../../nuxt/src/runtime/server/handler');
20+
handler = mod.default || mod;
21+
}, 30_000);
22+
23+
afterAll(async () => {
24+
process.env = oldEnv;
25+
});
26+
27+
it('flushes an error entry to MCP', async () => {
28+
// Spin up a tiny H3 server that mounts the Nuxt handler and POST a payload
29+
const app = createApp();
30+
const router = createRouter();
31+
const route = '/__nuxt-e2e';
32+
router.post(route, handler as any);
33+
app.use(router);
34+
35+
const server = createServer(toNodeListener(app));
36+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
37+
const address = server.address();
38+
const port = typeof address === 'object' && address ? (address as any).port : 0;
39+
40+
const res = await fetch(`http://127.0.0.1:${port}${route}`, {
41+
method: 'POST',
42+
headers: { 'content-type': 'application/json' },
43+
body: JSON.stringify({ sessionId: 'nuxte2e', entries: [{ level: 'error', text: 'nuxt e2e err' }] })
44+
});
45+
// h3 handler returns text body but we only care it didn't error and MCP forwarding occurred
46+
expect(res.status).toBe(204);
47+
48+
server.close();
49+
50+
await new Promise((r) => setTimeout(r, 200));
51+
const diag = await fetch(`${BASE}/__client-logs`);
52+
expect(diag.status).toBe(200);
53+
const body = await diag.text();
54+
expect(body).toContain('nuxt e2e err');
55+
});
56+
});
57+
58+

packages/mcp/test/e2e.vite.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2+
import { startMcpServer } from '../src/index';
3+
import browserEcho from '../../vite/src/index';
4+
5+
const HOST = '127.0.0.1';
6+
const PORT = 5182; // separate port from other smoke tests
7+
const BASE = `http://${HOST}:${PORT}`;
8+
9+
function makeServerMock() {
10+
const logs: any[] = [];
11+
const logger = {
12+
info: (m: string) => logs.push(['info', m]),
13+
warn: (m: string) => logs.push(['warn', m]),
14+
error: (m: string) => logs.push(['error', m])
15+
};
16+
const handlers: Array<[string, Function]> = [];
17+
const server: any = {
18+
config: { logger },
19+
middlewares: { use: (route: string, fn: Function) => handlers.push([route, fn]) }
20+
};
21+
return { server, logs, handlers };
22+
}
23+
24+
async function httpGet(url: string) {
25+
const res = await fetch(url);
26+
return { status: res.status, text: await res.text() };
27+
}
28+
29+
describe('E2E: Vite → MCP forwarding via middleware', () => {
30+
beforeAll(async () => {
31+
await startMcpServer({ host: HOST, port: PORT, endpoint: '/mcp', logsRoute: '/__client-logs' });
32+
}, 30_000);
33+
34+
afterAll(async () => {
35+
// streamable server persists for the test process
36+
});
37+
38+
it('forwards browser logs to MCP ingest', async () => {
39+
const { server, handlers } = makeServerMock();
40+
const plugin = browserEcho({ mcp: { url: BASE, suppressTerminal: true } });
41+
(plugin as any).configureServer(server);
42+
const [route, fn] = handlers[0];
43+
expect(route).toBe('/__client-logs');
44+
45+
// simulate incoming POST to vite middleware
46+
const req: any = new (class {
47+
method = 'POST';
48+
listeners: any = {};
49+
on(evt: string, cb: any) { this.listeners[evt] = cb; }
50+
trigger(data: any) { this.listeners['data']?.(data); this.listeners['end']?.(); }
51+
})();
52+
const res: any = { statusCode: 0, end: (cb?: any) => cb?.() };
53+
const payload = JSON.stringify({ sessionId: 'v1teee2e', entries: [{ level: 'error', text: 'vite→mcp e2e' }] });
54+
setTimeout(() => req.trigger(Buffer.from(payload)), 0);
55+
await new Promise<void>((resolve) => { res.end = () => { resolve(); }; fn(req, res, () => {}); });
56+
expect(res.statusCode).toBe(204);
57+
58+
// give the async forward a moment
59+
await new Promise((r) => setTimeout(r, 200));
60+
const diag = await httpGet(`${BASE}/__client-logs`);
61+
expect(diag.status).toBe(200);
62+
expect(diag.text).toContain('vite→mcp e2e');
63+
});
64+
});
65+
66+
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2+
import { startMcpServer } from '../src/index';
3+
4+
const HOST = '127.0.0.1';
5+
const PORT = 5181; // use a non-default port to avoid clashes with stdio test
6+
const BASE = `http://${HOST}:${PORT}`;
7+
8+
async function httpPost(path: string, body: any, headers: Record<string,string> = {}) {
9+
return await fetch(`${BASE}${path}`, {
10+
method: 'POST',
11+
headers: { 'content-type': 'application/json', ...headers },
12+
body: JSON.stringify(body)
13+
});
14+
}
15+
16+
async function httpGet(path: string, headers: Record<string,string> = {}) {
17+
return await fetch(`${BASE}${path}`, { method: 'GET', headers });
18+
}
19+
20+
describe('@browser-echo/mcp streamable HTTP transport (smoke)', () => {
21+
beforeAll(async () => {
22+
await startMcpServer({ host: HOST, port: PORT, endpoint: '/mcp', logsRoute: '/__client-logs' });
23+
}, 30_000);
24+
25+
afterAll(async () => {
26+
// streamable server keeps running for the test process; no explicit stop API exposed
27+
});
28+
29+
it('exposes health endpoint and rejects GET /mcp without session id', async () => {
30+
const health = await httpGet('/health');
31+
expect(health.status).toBe(200);
32+
const txt = await health.text();
33+
expect(txt).toBe('ok');
34+
35+
const resNoSid = await httpGet('/mcp');
36+
expect(resNoSid.status).toBe(405);
37+
});
38+
39+
it('ingests a browser log and shows in GET diagnostics', async () => {
40+
const payload = { sessionId: 'feedf00d', entries: [{ level: 'warn', text: 'http smoke' }] };
41+
const ingest = await httpPost('/__client-logs', payload);
42+
expect(ingest.status).toBe(204);
43+
44+
const diag = await httpGet('/__client-logs');
45+
expect(diag.status).toBe(200);
46+
const body = await diag.text();
47+
expect(body).toContain('http smoke');
48+
});
49+
});
50+
51+
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2+
import { fileURLToPath } from 'node:url';
3+
import { dirname, join } from 'node:path';
4+
import { McpTestClient } from './utils/mcpTestClient';
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = dirname(__filename);
8+
9+
describe('@browser-echo/mcp stdio transport (smoke)', () => {
10+
let client: McpTestClient;
11+
12+
beforeAll(async () => {
13+
const cli = join(__dirname, '..', 'bin', 'cli.mjs');
14+
client = new McpTestClient({ cliEntryPoint: cli });
15+
await client.connect();
16+
}, 30_000);
17+
18+
afterAll(async () => {
19+
await client?.close();
20+
});
21+
22+
it('lists tools', async () => {
23+
const out = await client.listTools();
24+
// minimal shape validation
25+
expect(out?.tools?.length).toBeGreaterThan(0);
26+
const names = (out.tools || []).map((t: any) => t.name);
27+
expect(names).toContain('get_logs');
28+
expect(names).toContain('clear_logs');
29+
});
30+
31+
it('ingests logs via HTTP ingest and retrieves via get_logs', async () => {
32+
// Post a log entry to the ingest endpoint that stdio server exposes
33+
const payload = { sessionId: 'deadbeef', entries: [{ level: 'error', text: 'stdio smoke' }] };
34+
const res = await fetch('http://127.0.0.1:5179/__client-logs', {
35+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload)
36+
});
37+
expect(res.status).toBe(204);
38+
39+
const got = await client.callTool('get_logs', { session: 'deadbeef', level: ['error'], includeStack: false, limit: 10 });
40+
const text = String(got?.content?.[0]?.text || '');
41+
expect(text).toContain('stdio smoke');
42+
});
43+
44+
it('clears logs via clear_logs', async () => {
45+
const result = await client.callTool('clear_logs', { scope: 'all' });
46+
const msg = JSON.stringify(result);
47+
expect(msg).toContain('cleared');
48+
});
49+
});
50+
51+

0 commit comments

Comments
 (0)