- Add close() to Redis, WS Client, WS Server, Worker Host, Worker Thread adapters for graceful teardown (cleanup subscriptions, restore handlers, clear maps) - WS Client now saves/restores original onmessage (consistent with WS Server) - WS Client dispatchEvent/addEventListener/removeEventListener are no-ops after close() - WS Server close() removes all connections and clears local listeners - Redis close() unsubscribes all channels and removes message listener - Worker Host/Thread close() restore original onmessage and clear callbacks - Worker Thread throws clear error if globalThis.postMessage is unavailable - Add double-call guard to WS Server removeConnection - Export new adapter interface types (RedisEventTarget, WebSocketClientEventTarget, etc.) - Add sideEffects: false to package.json for tree-shaking - Update architecture docs: lifecycle section, close() contract, adapter status updates - 22 new tests covering close(), handler restoration, idempotency, and context guard
811 lines
27 KiB
TypeScript
811 lines
27 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { createWebSocketClientEventTarget } from "../src/event-target-websocket-client.js";
|
|
import type { EventEnvelope, TypedEvent } from "../src/types.js";
|
|
|
|
type TestEvent = TypedEvent<string, EventEnvelope>;
|
|
|
|
function createMockWebSocket() {
|
|
const sent: string[] = [];
|
|
let onmessageHandler: ((event: { data: string }) => void) | null = null;
|
|
let oncloseHandler: ((event: { code: number; reason: string }) => void) | null = null;
|
|
|
|
const ws = {
|
|
send: vi.fn((data: string) => {
|
|
sent.push(data);
|
|
}),
|
|
get onmessage() {
|
|
return onmessageHandler;
|
|
},
|
|
set onmessage(handler: ((event: { data: string }) => void) | null) {
|
|
onmessageHandler = handler;
|
|
},
|
|
get onclose() {
|
|
return oncloseHandler;
|
|
},
|
|
set onclose(handler: ((event: { code: number; reason: string }) => void) | null) {
|
|
oncloseHandler = handler;
|
|
},
|
|
sent,
|
|
simulateMessage(data: string) {
|
|
if (onmessageHandler) {
|
|
onmessageHandler({ data });
|
|
}
|
|
},
|
|
simulateClose(code = 1000, reason = "") {
|
|
if (oncloseHandler) {
|
|
oncloseHandler({ code, reason });
|
|
}
|
|
onmessageHandler = null;
|
|
},
|
|
};
|
|
|
|
Object.defineProperty(ws, "onmessage", {
|
|
get() {
|
|
return onmessageHandler;
|
|
},
|
|
set(handler: ((event: { data: string }) => void) | null) {
|
|
onmessageHandler = handler;
|
|
},
|
|
});
|
|
|
|
Object.defineProperty(ws, "onclose", {
|
|
get() {
|
|
return oncloseHandler;
|
|
},
|
|
set(handler: ((event: { code: number; reason: string }) => void) | null) {
|
|
oncloseHandler = handler;
|
|
},
|
|
});
|
|
|
|
return ws;
|
|
}
|
|
|
|
describe("createWebSocketClientEventTarget", () => {
|
|
describe("dispatchEvent (send path)", () => {
|
|
it("serializes envelope detail and calls ws.send", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const envelope: EventEnvelope<"call.responded", { status: string }> = {
|
|
type: "call.responded",
|
|
id: "uuid-123",
|
|
payload: { status: "ok" },
|
|
};
|
|
|
|
const event = new CustomEvent("call.responded:uuid-123", {
|
|
detail: envelope,
|
|
}) as TestEvent;
|
|
|
|
eventTarget.dispatchEvent(event);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
expect(ws.send).toHaveBeenCalledWith(JSON.stringify(envelope));
|
|
});
|
|
|
|
it("returns true from dispatchEvent", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const event = new CustomEvent("test:event", {
|
|
detail: { type: "test", id: "event", payload: null },
|
|
}) as TestEvent;
|
|
|
|
const result = eventTarget.dispatchEvent(event);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("propagates ws.send() errors to caller", () => {
|
|
const ws = createMockWebSocket();
|
|
ws.send.mockImplementation(() => {
|
|
throw new Error("WebSocket is not open");
|
|
});
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const event = new CustomEvent("test:event", {
|
|
detail: { type: "test", id: "event", payload: null },
|
|
}) as TestEvent;
|
|
|
|
expect(() => eventTarget.dispatchEvent(event)).toThrow("WebSocket is not open");
|
|
});
|
|
});
|
|
|
|
describe("addEventListener (subscribe path)", () => {
|
|
it("sends __subscribe control event on first listener for a topic", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("call.responded:uuid-123", listener);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
expect(ws.send).toHaveBeenCalledWith(
|
|
JSON.stringify({
|
|
type: "__subscribe",
|
|
id: "",
|
|
payload: { topic: "call.responded:uuid-123" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("sends __subscribe only once when multiple listeners are added for the same topic", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener1 = vi.fn();
|
|
const listener2 = vi.fn();
|
|
eventTarget.addEventListener("message.sent:msg1", listener1);
|
|
eventTarget.addEventListener("message.sent:msg1", listener2);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not send __subscribe when callback is null", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
eventTarget.addEventListener("topic:a", null as any);
|
|
|
|
expect(ws.send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("supports EventListenerObject with handleEvent", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const handleEvent = vi.fn();
|
|
const listenerObject = { handleEvent };
|
|
|
|
eventTarget.addEventListener("obj:test", listenerObject);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
expect(ws.send).toHaveBeenCalledWith(
|
|
JSON.stringify({
|
|
type: "__subscribe",
|
|
id: "",
|
|
payload: { topic: "obj:test" },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("removeEventListener (unsubscribe path)", () => {
|
|
it("sends __unsubscribe when the last listener for a topic is removed", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
|
|
eventTarget.removeEventListener("topic:a", listener);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(2);
|
|
expect(ws.send).toHaveBeenCalledWith(
|
|
JSON.stringify({
|
|
type: "__unsubscribe",
|
|
id: "",
|
|
payload: { topic: "topic:a" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not send __unsubscribe while other listeners remain for the same topic", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener1 = vi.fn();
|
|
const listener2 = vi.fn();
|
|
eventTarget.addEventListener("event:type1", listener1);
|
|
eventTarget.addEventListener("event:type1", listener2);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
|
|
eventTarget.removeEventListener("event:type1", listener1);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
|
|
eventTarget.removeEventListener("event:type1", listener2);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(2);
|
|
expect(ws.send).toHaveBeenCalledWith(
|
|
JSON.stringify({
|
|
type: "__unsubscribe",
|
|
id: "",
|
|
payload: { topic: "event:type1" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not send __unsubscribe when removing a callback that was never registered", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener1 = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener1);
|
|
|
|
const unregisteredListener = vi.fn();
|
|
eventTarget.removeEventListener("topic:a", unregisteredListener);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not send __unsubscribe when callback is null", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
eventTarget.addEventListener("topic:a", null as any);
|
|
|
|
eventTarget.removeEventListener("topic:a", null as any);
|
|
|
|
expect(ws.send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("supports EventListenerObject with handleEvent for removal", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const handleEvent = vi.fn();
|
|
const listenerObject = { handleEvent };
|
|
|
|
eventTarget.addEventListener("obj:test", listenerObject);
|
|
expect(ws.send).toHaveBeenCalledWith(
|
|
JSON.stringify({
|
|
type: "__subscribe",
|
|
id: "",
|
|
payload: { topic: "obj:test" },
|
|
}),
|
|
);
|
|
|
|
eventTarget.removeEventListener("obj:test", listenerObject);
|
|
expect(ws.send).toHaveBeenCalledWith(
|
|
JSON.stringify({
|
|
type: "__unsubscribe",
|
|
id: "",
|
|
payload: { topic: "obj:test" },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("receive path (ws.onmessage)", () => {
|
|
it("parses envelope, creates CustomEvent with type:id topic, and dispatches to listeners", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("message.sent:msg1", listener);
|
|
|
|
const envelope: EventEnvelope = {
|
|
type: "message.sent",
|
|
id: "msg1",
|
|
payload: "hello world",
|
|
};
|
|
ws.simulateMessage(JSON.stringify(envelope));
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
const receivedEvent = listener.mock.calls[0][0] as TestEvent;
|
|
expect(receivedEvent.type).toBe("message.sent:msg1");
|
|
expect(receivedEvent.detail).toEqual(envelope);
|
|
});
|
|
|
|
it("delivers messages to all listeners on the same topic", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener1 = vi.fn();
|
|
const listener2 = vi.fn();
|
|
eventTarget.addEventListener("topic:x", listener1);
|
|
eventTarget.addEventListener("topic:x", listener2);
|
|
|
|
const envelope: EventEnvelope = { type: "topic", id: "x", payload: "data" };
|
|
ws.simulateMessage(JSON.stringify(envelope));
|
|
|
|
expect(listener1).toHaveBeenCalledTimes(1);
|
|
expect(listener2).toHaveBeenCalledTimes(1);
|
|
expect((listener1.mock.calls[0][0] as TestEvent).detail).toEqual(envelope);
|
|
expect((listener2.mock.calls[0][0] as TestEvent).detail).toEqual(envelope);
|
|
});
|
|
|
|
it("ignores messages for topics with no registered listeners", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "other", id: "b", payload: null };
|
|
ws.simulateMessage(JSON.stringify(envelope));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("malformed JSON handling", () => {
|
|
it("silently ignores malformed JSON and logs a warning", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
ws.simulateMessage("not valid json{{");
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to parse WebSocket message"),
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("continues delivering valid messages after a parse error", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
ws.simulateMessage("broken{{{");
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
|
|
const envelope: EventEnvelope = { type: "topic", id: "a", payload: "good" };
|
|
ws.simulateMessage(JSON.stringify(envelope));
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect((listener.mock.calls[0][0] as TestEvent).detail).toEqual(envelope);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("control events from server", () => {
|
|
it("silently ignores __subscribe control events received from server", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
ws.simulateMessage(JSON.stringify({
|
|
type: "__subscribe",
|
|
id: "",
|
|
payload: { topic: "topic:a" },
|
|
}));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("silently ignores __unsubscribe control events received from server", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
ws.simulateMessage(JSON.stringify({
|
|
type: "__unsubscribe",
|
|
id: "",
|
|
payload: { topic: "topic:a" },
|
|
}));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores any event type starting with __", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("__custom:thing", listener);
|
|
|
|
ws.simulateMessage(JSON.stringify({
|
|
type: "__custom",
|
|
id: "thing",
|
|
payload: null,
|
|
}));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("EventEnvelope round-trip", () => {
|
|
it("round-trips full { type, id, payload } envelope through send and receive", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("user.joined:user-99", listener);
|
|
|
|
const originalEnvelope: EventEnvelope<"user.joined", { name: string; role: string }> = {
|
|
type: "user.joined",
|
|
id: "user-99",
|
|
payload: { name: "Bob", role: "admin" },
|
|
};
|
|
|
|
const event = new CustomEvent("user.joined:user-99", {
|
|
detail: originalEnvelope,
|
|
}) as TestEvent;
|
|
|
|
eventTarget.dispatchEvent(event);
|
|
|
|
const dispatchedData = ws.sent[1];
|
|
expect(dispatchedData).toBe(JSON.stringify(originalEnvelope));
|
|
|
|
const sentData = dispatchedData;
|
|
|
|
ws.simulateMessage(sentData);
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
const receivedDetail = (listener.mock.calls[0][0] as TestEvent).detail as EventEnvelope;
|
|
expect(receivedDetail).toEqual(originalEnvelope);
|
|
expect(receivedDetail.type).toBe("user.joined");
|
|
expect(receivedDetail.id).toBe("user-99");
|
|
expect(receivedDetail.payload).toEqual({ name: "Bob", role: "admin" });
|
|
});
|
|
|
|
it("round-trips envelope with null payload", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("ping:1", listener);
|
|
|
|
const envelope: EventEnvelope = { type: "ping", id: "1", payload: null };
|
|
ws.simulateMessage(JSON.stringify(envelope));
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
const receivedDetail = (listener.mock.calls[0][0] as TestEvent).detail as EventEnvelope;
|
|
expect(receivedDetail).toEqual(envelope);
|
|
});
|
|
});
|
|
|
|
describe("topic scoping", () => {
|
|
it("forms topic from envelope type and id fields", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("user.action:abc-123", listener);
|
|
|
|
const envelope: EventEnvelope = {
|
|
type: "user.action",
|
|
id: "abc-123",
|
|
payload: { done: true },
|
|
};
|
|
ws.simulateMessage(JSON.stringify(envelope));
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect((listener.mock.calls[0][0] as TestEvent).type).toBe("user.action:abc-123");
|
|
});
|
|
|
|
it("does not match when type differs even if id is the same", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("user.created:id1", listener);
|
|
|
|
ws.simulateMessage(JSON.stringify({ type: "user.deleted", id: "id1", payload: null }));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not match when id differs even if type is the same", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("event:alpha", listener);
|
|
|
|
ws.simulateMessage(JSON.stringify({ type: "event", id: "beta", payload: null }));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("forms topic with undefined id as 'type:undefined' which does not match 'type:'", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("ping:", listener);
|
|
|
|
ws.simulateMessage(JSON.stringify({ type: "ping", payload: "hello" }));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("envelope validation on receive", () => {
|
|
it("ignores messages where type is not a string", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
ws.simulateMessage(JSON.stringify({ type: 123, id: "a", payload: null }));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores messages where type is undefined", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
ws.simulateMessage(JSON.stringify({ id: "a", payload: null }));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores messages where type is null", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
ws.simulateMessage(JSON.stringify({ type: null, id: "a", payload: null }));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("reconnection", () => {
|
|
it("restores subscriptions on a new event target with a new WebSocket", () => {
|
|
const ws1 = createMockWebSocket();
|
|
const eventTarget1 = createWebSocketClientEventTarget<TestEvent>(ws1 as any);
|
|
|
|
const listener1 = vi.fn();
|
|
eventTarget1.addEventListener("order.created:ord-1", listener1);
|
|
expect(ws1.send).toHaveBeenCalledTimes(1);
|
|
|
|
ws1.simulateClose();
|
|
|
|
const ws2 = createMockWebSocket();
|
|
const eventTarget2 = createWebSocketClientEventTarget<TestEvent>(ws2 as any);
|
|
|
|
const listener2 = vi.fn();
|
|
eventTarget2.addEventListener("order.created:ord-1", listener2);
|
|
expect(ws2.send).toHaveBeenCalledTimes(1);
|
|
expect(ws2.send).toHaveBeenCalledWith(
|
|
JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "order.created:ord-1" } }),
|
|
);
|
|
|
|
const envelope: EventEnvelope = { type: "order.created", id: "ord-1", payload: { item: "book" } };
|
|
ws2.simulateMessage(JSON.stringify(envelope));
|
|
|
|
expect(listener2).toHaveBeenCalledTimes(1);
|
|
expect((listener2.mock.calls[0][0] as TestEvent).detail).toEqual(envelope);
|
|
});
|
|
|
|
it("does not receive messages on old connection after reconnect", () => {
|
|
const ws1 = createMockWebSocket();
|
|
const eventTarget1 = createWebSocketClientEventTarget<TestEvent>(ws1 as any);
|
|
|
|
const listener1 = vi.fn();
|
|
eventTarget1.addEventListener("chat.msg:1", listener1);
|
|
|
|
ws1.simulateClose();
|
|
|
|
const ws2 = createMockWebSocket();
|
|
const eventTarget2 = createWebSocketClientEventTarget<TestEvent>(ws2 as any);
|
|
|
|
const listener2 = vi.fn();
|
|
eventTarget2.addEventListener("chat.msg:1", listener2);
|
|
|
|
ws1.simulateMessage(JSON.stringify({ type: "chat.msg", id: "1", payload: "stale" }));
|
|
|
|
expect(listener1).not.toHaveBeenCalled();
|
|
expect(listener2).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("connection close", () => {
|
|
it("does not send __unsubscribe on close since lifecycle is caller-managed", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
|
|
ws.simulateClose();
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("close()", () => {
|
|
it("sends __unsubscribe for all active subscriptions", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener1 = vi.fn();
|
|
const listener2 = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener1);
|
|
eventTarget.addEventListener("topic:b", listener2);
|
|
|
|
(ws.send as ReturnType<typeof vi.fn>).mockClear();
|
|
|
|
eventTarget.close();
|
|
|
|
const sent = ws.sent.map((s: string) => JSON.parse(s));
|
|
const unsubscribes = sent.filter((e: any) => e.type === "__unsubscribe");
|
|
expect(unsubscribes).toHaveLength(2);
|
|
const topics = unsubscribes.map((e: any) => e.payload.topic);
|
|
expect(topics).toContain("topic:a");
|
|
expect(topics).toContain("topic:b");
|
|
});
|
|
|
|
it("restores original onmessage handler", () => {
|
|
const ws = createMockWebSocket();
|
|
const originalOnmessage = vi.fn();
|
|
ws.onmessage = originalOnmessage;
|
|
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
expect(ws.onmessage).not.toBe(originalOnmessage);
|
|
|
|
eventTarget.close();
|
|
|
|
expect(ws.onmessage).toBe(originalOnmessage);
|
|
});
|
|
|
|
it("does not deliver messages after close", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
eventTarget.close();
|
|
|
|
const envelope: EventEnvelope = { type: "topic", id: "a", payload: "hello" };
|
|
ws.simulateMessage(JSON.stringify(envelope));
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not send __subscribe after close", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
eventTarget.close();
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
expect(ws.send).not.toHaveBeenCalledWith(
|
|
expect.stringContaining("__subscribe"),
|
|
);
|
|
});
|
|
|
|
it("dispatchEvent returns true but does not send after close", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
eventTarget.close();
|
|
|
|
(ws.send as ReturnType<typeof vi.fn>).mockClear();
|
|
|
|
const event = new CustomEvent("test:event", {
|
|
detail: { type: "test", id: "event", payload: null },
|
|
}) as TestEvent;
|
|
|
|
const result = eventTarget.dispatchEvent(event);
|
|
expect(result).toBe(true);
|
|
expect(ws.send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("is idempotent", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener);
|
|
|
|
eventTarget.close();
|
|
eventTarget.close();
|
|
|
|
const sentCalls = ws.sent.filter((s: string) => JSON.parse(s).type === "__unsubscribe");
|
|
expect(sentCalls).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("dispatchEvent (send path) edge cases", () => {
|
|
it("sends envelope with null payload", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const event = new CustomEvent("notify:1", {
|
|
detail: { type: "notify", id: "1", payload: null },
|
|
}) as TestEvent;
|
|
|
|
eventTarget.dispatchEvent(event);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
const sent = JSON.parse(ws.sent[0]);
|
|
expect(sent.type).toBe("notify");
|
|
expect(sent.id).toBe("1");
|
|
expect(sent.payload).toBeNull();
|
|
});
|
|
|
|
it("sends multiple events in order", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const event1 = new CustomEvent("a:1", {
|
|
detail: { type: "a", id: "1", payload: "first" },
|
|
}) as TestEvent;
|
|
const event2 = new CustomEvent("b:2", {
|
|
detail: { type: "b", id: "2", payload: "second" },
|
|
}) as TestEvent;
|
|
|
|
eventTarget.dispatchEvent(event1);
|
|
eventTarget.dispatchEvent(event2);
|
|
|
|
expect(ws.sent).toHaveLength(2);
|
|
expect(JSON.parse(ws.sent[0])).toEqual({ type: "a", id: "1", payload: "first" });
|
|
expect(JSON.parse(ws.sent[1])).toEqual({ type: "b", id: "2", payload: "second" });
|
|
});
|
|
});
|
|
|
|
describe("subscription reference counting", () => {
|
|
it("re-sends __subscribe after all listeners removed and a new one added", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listener1 = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener1);
|
|
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
|
|
eventTarget.removeEventListener("topic:a", listener1);
|
|
expect(ws.send).toHaveBeenCalledTimes(2);
|
|
|
|
const listener2 = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listener2);
|
|
expect(ws.send).toHaveBeenCalledTimes(3);
|
|
|
|
expect(ws.send).toHaveBeenNthCalledWith(1,
|
|
JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "topic:a" } }),
|
|
);
|
|
expect(ws.send).toHaveBeenNthCalledWith(2,
|
|
JSON.stringify({ type: "__unsubscribe", id: "", payload: { topic: "topic:a" } }),
|
|
);
|
|
expect(ws.send).toHaveBeenNthCalledWith(3,
|
|
JSON.stringify({ type: "__subscribe", id: "", payload: { topic: "topic:a" } }),
|
|
);
|
|
});
|
|
|
|
it("tracks separate topics independently", () => {
|
|
const ws = createMockWebSocket();
|
|
const eventTarget = createWebSocketClientEventTarget<TestEvent>(ws as any);
|
|
|
|
const listenerA = vi.fn();
|
|
const listenerB = vi.fn();
|
|
eventTarget.addEventListener("topic:a", listenerA);
|
|
eventTarget.addEventListener("topic:b", listenerB);
|
|
|
|
expect(ws.send).toHaveBeenCalledTimes(2);
|
|
|
|
eventTarget.removeEventListener("topic:a", listenerA);
|
|
expect(ws.send).toHaveBeenCalledTimes(3);
|
|
|
|
expect(ws.send).toHaveBeenNthCalledWith(3,
|
|
JSON.stringify({ type: "__unsubscribe", id: "", payload: { topic: "topic:a" } }),
|
|
);
|
|
|
|
const listenerB2 = vi.fn();
|
|
eventTarget.addEventListener("topic:b", listenerB2);
|
|
expect(ws.send).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
}); |