import { afterEach, expect, test, vi } from "vitest" import { Client } from "#Source/socket/base.index.ts" type FakeEventMap = { close: { code: number; reason: string } error: Event message: { data: string } open: Event } class FakeWebSocket { static readonly CONNECTING = 0 static readonly OPEN = 1 static readonly CLOSING = 2 static readonly CLOSED = 3 static instances: FakeWebSocket[] = [] readonly url: string readonly sentMessages: string[] readyState: number private readonly listeners: { [K in keyof FakeEventMap]: Set<(event: FakeEventMap[K]) => void> } constructor(url: string) { this.url = url this.sentMessages = [] this.readyState = FakeWebSocket.CONNECTING this.listeners = { close: new Set(), error: new Set(), message: new Set(), open: new Set(), } FakeWebSocket.instances.push(this) } addEventListener( type: K, listener: (event: FakeEventMap[K]) => void, ): void { this.listeners[type].add(listener) } removeEventListener( type: K, listener: (event: FakeEventMap[K]) => void, ): void { this.listeners[type].delete(listener) } send(message: string): void { if (this.readyState !== FakeWebSocket.OPEN) { throw new Error("FakeWebSocket is not open.") } this.sentMessages.push(message) } close(): void { if (this.readyState === FakeWebSocket.CLOSED) { return } this.readyState = FakeWebSocket.CLOSED this.emit("close", { code: 1_000, reason: "" }) } simulateOpen(): void { this.readyState = FakeWebSocket.OPEN this.emit("open", new Event("open")) } simulateMessage(message: unknown): void { this.emit("message", { data: JSON.stringify(message) }) } simulateError(): void { this.emit("error", new Event("error")) } private emit(type: K, event: FakeEventMap[K]): void { for (const listener of this.listeners[type]) { listener(event) } } } afterEach(() => { FakeWebSocket.instances = [] vi.unstubAllGlobals() vi.restoreAllMocks() }) test("Client.SocketUnit queues work until ready, deduplicates status events, and keeps runtime listeners after error", async () => { vi.stubGlobal("WebSocket", FakeWebSocket) const socketUnit = new Client.SocketUnit<{ type: string }>({ url: "ws://socket.test", }) const statuses: string[] = [] const messages: Array<{ type: string }> = [] socketUnit.eventManager.subscribe("status", (status) => { statuses.push(status) }) socketUnit.eventManager.subscribe("message", (message) => { messages.push(message) }) socketUnit.setClientId("client-1") socketUnit.sendMessage({ type: "queued-before-open" }) expect(socketUnit.getSnapshot().pendingActionCount).toBe(1) const openPromise = socketUnit.open() const firstWebSocket = FakeWebSocket.instances[0]! expect(socketUnit.getStatus()).toBe("CONNECTING") firstWebSocket.simulateOpen() await openPromise expect(firstWebSocket.sentMessages).toEqual([JSON.stringify({ type: "queued-before-open" })]) expect(statuses).toEqual(["CONNECTING", "OPEN"]) firstWebSocket.simulateMessage({ type: "message-1" }) firstWebSocket.simulateMessage({ type: "message-2" }) firstWebSocket.simulateError() firstWebSocket.simulateMessage({ type: "message-3" }) expect(messages).toEqual([{ type: "message-1" }, { type: "message-2" }, { type: "message-3" }]) expect(statuses).toEqual(["CONNECTING", "OPEN"]) await expect(socketUnit.safeOpen()).rejects.toThrow("Socket is already open") await socketUnit.close() expect(socketUnit.getStatus()).toBe("UNINSTANTIATED") let resetCount = 0 await socketUnit.reset(() => { resetCount = resetCount + 1 }) expect(resetCount).toBe(1) expect(socketUnit.getSnapshot()).toMatchObject({ clientId: undefined, pendingActionCount: 0, status: "UNINSTANTIATED", }) }) test("Client.Socket emits connect close and message events from stable ready-to-work transitions", async () => { vi.stubGlobal("WebSocket", FakeWebSocket) const socket = new Client.Socket<{ type: string }>({ url: "ws://socket.test", }) const connectClientIds: Array = [] const closeClientIds: Array = [] const forwardedMessages: Array<{ clientId: string; message: { type: string } }> = [] socket.eventManager.subscribe("connect", ({ clientId }) => { connectClientIds.push(clientId) }) socket.eventManager.subscribe("close", ({ clientId }) => { closeClientIds.push(clientId) }) socket.eventManager.subscribe("message", (payload) => { forwardedMessages.push(payload) }) socket.setClientId("client-2") socket.sendMessage({ type: "queued-before-open" }) const openPromise = socket.open() const firstWebSocket = FakeWebSocket.instances[0]! firstWebSocket.simulateOpen() await openPromise expect(connectClientIds).toEqual(["client-2"]) expect(firstWebSocket.sentMessages).toEqual([JSON.stringify({ type: "queued-before-open" })]) firstWebSocket.simulateMessage({ type: "hello" }) firstWebSocket.simulateMessage({ type: "hello-again" }) expect(forwardedMessages).toEqual([ { clientId: "client-2", message: { type: "hello" } }, { clientId: "client-2", message: { type: "hello-again" } }, ]) expect(connectClientIds).toEqual(["client-2"]) await socket.close() expect(closeClientIds).toEqual(["client-2"]) socket.reset() expect(socket.getSnapshot()).toMatchObject({ clientId: "client-2", isReadyToWork: false, status: "UNINSTANTIATED", }) })