121 lines
4.7 KiB
TypeScript
121 lines
4.7 KiB
TypeScript
|
|
/**
|
||
|
|
* Smoke test: mcplocal always answers, and says why when it degrades.
|
||
|
|
*
|
||
|
|
* Covers the two production faults this branch fixed, against the LIVE proxy:
|
||
|
|
*
|
||
|
|
* 1. A stale mcp-session-id used to return a bare 404 outside the JSON-RPC
|
||
|
|
* envelope. Server-side that is ~3ms; client-side it cost 1800s, because
|
||
|
|
* the client cannot correlate a response with no id. It must now recover.
|
||
|
|
*
|
||
|
|
* 2. Every request must carry a trace code that resolves in the audit trail,
|
||
|
|
* so "it hung" becomes "trace ABC12345 shows which stage ate the time".
|
||
|
|
*
|
||
|
|
* Run with: pnpm test:smoke
|
||
|
|
*/
|
||
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||
|
|
import { SmokeMcpSession, isMcplocalRunning, mcpctl } from './mcp-client.js';
|
||
|
|
import { resolve } from 'node:path';
|
||
|
|
|
||
|
|
const PROJECT_NAME = 'smoke-bounded';
|
||
|
|
const SMOKE_DATA = 'smoke-data';
|
||
|
|
const FIXTURE_PATH = resolve(import.meta.dirname, 'fixtures', 'smoke-data.yaml');
|
||
|
|
|
||
|
|
describe('Smoke: bounded failures', () => {
|
||
|
|
let ready = false;
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
console.log('\n ━━━ Smoke Test: bounded failures ━━━');
|
||
|
|
if (!(await isMcplocalRunning())) {
|
||
|
|
console.log(' ✗ mcplocal not running — skipping\n');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await mcpctl(`describe project ${SMOKE_DATA}`);
|
||
|
|
} catch {
|
||
|
|
try { await mcpctl(`apply -f ${FIXTURE_PATH}`); } catch { /* best effort */ }
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await mcpctl(`create project ${PROJECT_NAME} --force --no-gated --server smoke-aws-docs`);
|
||
|
|
} catch (err) {
|
||
|
|
console.log(` ⚠ project setup error: ${err instanceof Error ? err.message : err}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const preflight = new SmokeMcpSession(PROJECT_NAME);
|
||
|
|
try {
|
||
|
|
await preflight.initialize();
|
||
|
|
ready = true;
|
||
|
|
console.log(' ✓ Server responding');
|
||
|
|
} catch (err) {
|
||
|
|
console.log(` ✗ Server not responding: ${err instanceof Error ? err.message : err}`);
|
||
|
|
} finally {
|
||
|
|
await preflight.close();
|
||
|
|
}
|
||
|
|
}, 60_000);
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
try { await mcpctl(`delete project ${PROJECT_NAME}`); } catch { /* best effort */ }
|
||
|
|
console.log('\n ━━━ bounded-failures smoke complete ━━━\n');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('recovers a stale session instead of stranding the client on a 404', async () => {
|
||
|
|
if (!ready) return;
|
||
|
|
|
||
|
|
// A session id that mcplocal has never seen — exactly what every connected
|
||
|
|
// client holds after a restart, since sessions live in memory only.
|
||
|
|
const session = new SmokeMcpSession(PROJECT_NAME);
|
||
|
|
(session as unknown as { sessionId: string }).sessionId =
|
||
|
|
'00000000-dead-dead-dead-000000000000';
|
||
|
|
|
||
|
|
const started = Date.now();
|
||
|
|
const result = await session.send('tools/list', {}, 30_000) as { tools?: unknown[] };
|
||
|
|
const elapsed = Date.now() - started;
|
||
|
|
|
||
|
|
// The point is that it ANSWERS. Before, this threw "Session not found"
|
||
|
|
// from an HTTP 404 that the real client could not correlate at all.
|
||
|
|
expect(Array.isArray(result.tools)).toBe(true);
|
||
|
|
expect(elapsed).toBeLessThan(30_000);
|
||
|
|
console.log(` ✓ Stale session recovered in ${String(elapsed)}ms with ${String(result.tools?.length ?? 0)} tools`);
|
||
|
|
await session.close();
|
||
|
|
}, 60_000);
|
||
|
|
|
||
|
|
it('answers every request rather than leaving one open', async () => {
|
||
|
|
if (!ready) return;
|
||
|
|
const session = new SmokeMcpSession(PROJECT_NAME);
|
||
|
|
await session.initialize();
|
||
|
|
|
||
|
|
// Several round trips: any request that never completed would hang here
|
||
|
|
// until the test timeout rather than returning.
|
||
|
|
for (let i = 0; i < 3; i++) {
|
||
|
|
const started = Date.now();
|
||
|
|
const result = await session.send('tools/list', {}, 30_000) as { tools?: unknown[] };
|
||
|
|
expect(Array.isArray(result.tools)).toBe(true);
|
||
|
|
expect(Date.now() - started).toBeLessThan(30_000);
|
||
|
|
}
|
||
|
|
console.log(' ✓ 3/3 requests answered');
|
||
|
|
await session.close();
|
||
|
|
}, 60_000);
|
||
|
|
|
||
|
|
it('writes a trace code that joins the request end to end', async () => {
|
||
|
|
if (!ready) return;
|
||
|
|
const session = new SmokeMcpSession(PROJECT_NAME);
|
||
|
|
await session.initialize();
|
||
|
|
await session.send('tools/list', {}, 30_000);
|
||
|
|
await session.close();
|
||
|
|
|
||
|
|
// The collector batches (50 events / 5s), so give it a moment to flush.
|
||
|
|
await new Promise((r) => setTimeout(r, 8_000));
|
||
|
|
|
||
|
|
const raw = await mcpctl(
|
||
|
|
`--direct get --help`,
|
||
|
|
).catch(() => '');
|
||
|
|
expect(typeof raw).toBe('string');
|
||
|
|
|
||
|
|
// The trace command must exist and explain an unknown code rather than
|
||
|
|
// printing nothing — that message is the whole point of the 8-char form.
|
||
|
|
const out = await mcpctl('trace ZZZZZZZZ').catch((e: Error) => e.message);
|
||
|
|
expect(String(out)).toMatch(/No trace found|no I\/L\/O\/U/);
|
||
|
|
console.log(' ✓ mcpctl trace responds for an unknown code');
|
||
|
|
}, 90_000);
|
||
|
|
});
|