124 lines
4.1 KiB
TypeScript
124 lines
4.1 KiB
TypeScript
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import { MCP_CODES } from "../../error-codes/error-codes.js";
|
|
import type { JsonRpcMessage } from "../../schemas/schemas.js";
|
|
import type {
|
|
EventSourceCtor,
|
|
EventSourceLike,
|
|
FetchLike,
|
|
} from "../../types/types.js";
|
|
import { createHttpSseTransport } from "./http-sse.js";
|
|
|
|
class FakeEventSource implements EventSourceLike {
|
|
public static instances: FakeEventSource[] = [];
|
|
private listeners: ((ev: { data: string }) => void)[] = [];
|
|
private _closed = false;
|
|
public constructor(public readonly url: string) {
|
|
FakeEventSource.instances.push(this);
|
|
}
|
|
public addEventListener(
|
|
_type: "message",
|
|
handler: (ev: { data: string }) => void
|
|
): void {
|
|
this.listeners.push(handler);
|
|
}
|
|
public close(): void {
|
|
this._closed = true;
|
|
}
|
|
public get closed(): boolean {
|
|
return this._closed;
|
|
}
|
|
public emit(data: string): void {
|
|
for (const l of this.listeners) l({ data });
|
|
}
|
|
}
|
|
|
|
const FakeEventSourceCtor = FakeEventSource as unknown as EventSourceCtor;
|
|
|
|
describe("HttpSseTransport", () => {
|
|
it("posts requests via fetchImpl", { timeout: 5000 }, async () => {
|
|
const calls: Array<{ url: string; body: string }> = [];
|
|
const fetchImpl: FetchLike = async (url, init) => {
|
|
calls.push({ url, body: init.body });
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => "",
|
|
};
|
|
};
|
|
FakeEventSource.instances = [];
|
|
const t = createHttpSseTransport({
|
|
endpoint: "http://x",
|
|
fetchImpl,
|
|
eventSourceImpl: FakeEventSourceCtor,
|
|
});
|
|
await t.send({ jsonrpc: "2.0", id: 1, method: "ping" });
|
|
expect(calls.length).toBe(1);
|
|
expect(calls[0]?.url).toBe("http://x");
|
|
await t.close();
|
|
});
|
|
|
|
it("dispatches messages from eventSourceImpl", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
FakeEventSource.instances = [];
|
|
const t = createHttpSseTransport({
|
|
endpoint: "http://x",
|
|
fetchImpl: async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => "",
|
|
}),
|
|
eventSourceImpl: FakeEventSourceCtor,
|
|
});
|
|
const received: JsonRpcMessage[] = [];
|
|
t.onMessage((m) => received.push(m));
|
|
const es = FakeEventSource.instances[0];
|
|
if (es === undefined) throw new Error("no es");
|
|
es.emit(
|
|
JSON.stringify({ jsonrpc: "2.0", id: 1, result: { ok: true } })
|
|
);
|
|
es.emit("garbage-not-json");
|
|
// JSON-valid but schema-invalid — exercises validated.success false.
|
|
es.emit(JSON.stringify({ not: "a jsonrpc message" }));
|
|
await Promise.resolve();
|
|
expect(received.length).toBe(1);
|
|
await t.close();
|
|
});
|
|
|
|
it("maps non-2xx responses to TRANSPORT_ERROR", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
FakeEventSource.instances = [];
|
|
const t = createHttpSseTransport({
|
|
endpoint: "http://x",
|
|
fetchImpl: async () => ({
|
|
ok: false,
|
|
status: 503,
|
|
text: async () => "boom",
|
|
}),
|
|
eventSourceImpl: FakeEventSourceCtor,
|
|
});
|
|
await expect(
|
|
t.send({ jsonrpc: "2.0", id: 1, method: "ping" })
|
|
).rejects.toMatchObject({ code: MCP_CODES.TRANSPORT_ERROR });
|
|
await t.close();
|
|
});
|
|
|
|
it("raises TRANSPORT_CLOSED after close", { timeout: 5000 }, async () => {
|
|
FakeEventSource.instances = [];
|
|
const t = createHttpSseTransport({
|
|
endpoint: "http://x",
|
|
fetchImpl: async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => "",
|
|
}),
|
|
eventSourceImpl: FakeEventSourceCtor,
|
|
});
|
|
await t.close();
|
|
await expect(
|
|
t.send({ jsonrpc: "2.0", id: 1, method: "ping" })
|
|
).rejects.toMatchObject({ code: MCP_CODES.TRANSPORT_CLOSED });
|
|
});
|
|
});
|