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; 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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(ws1 as any); const listener1 = vi.fn(); eventTarget1.addEventListener("chat.msg:1", listener1); ws1.simulateClose(); const ws2 = createMockWebSocket(); const eventTarget2 = createWebSocketClientEventTarget(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(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(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).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(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(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(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(ws as any); eventTarget.close(); (ws.send as ReturnType).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(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(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(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(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(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); }); }); });