import { vi } from 'vitest'; import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract'; type ServerEvent = keyof ServerToClientEvents; type ClientEvent = keyof ClientToServerEvents; type ServerHandler = ServerToClientEvents[K]; type ClientPayload = Parameters[0]; export interface EmittedEvent { event: K; payload: ClientPayload; } /** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */ export interface FakeChatSocket { connected: boolean; /** Mirrors socket.io-client's `Socket.id`: the connection identity the server * echoes in a `chat:send-capability` payload. The generation-bound send * protocol accepts an advertisement only when `payload.connectionId === id`. */ id: string; connect(): FakeChatSocket; on(event: K, handler: ServerHandler): FakeChatSocket; off(event: K, handler: ServerHandler): FakeChatSocket; emit(event: K, payload: ClientPayload): FakeChatSocket; } /** * A typed in-memory stand-in for `getSocket()`. Unlike a bare * `(event: string, payload: unknown) => void` mock, every public method here is * checked against the real `/chat` contract — a typo'd event name or a payload * missing a required field fails to compile instead of silently no-op'ing at * runtime. */ /** Socket.IO's built-in connection-state events. Not part of the app-level * ServerToClientEvents contract, but real sockets always support them and * `useChatConnection` registers a `disconnect` handler on the real socket. */ type LifecycleEvent = 'connect' | 'disconnect'; export function createFakeChatSocket(): { socket: FakeChatSocket; listeners: Map void>>; emitted: EmittedEvent[]; serverEmit( event: K, payload: Parameters[0], ): void; /** Escape hatch for malformed-payload tests: bypasses the compile-time * payload contract to simulate a genuinely untrusted runtime value from the * server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */ serverEmitRaw(event: ServerEvent, payload: unknown): void; /** Simulates a transient Socket.IO `disconnect` — fires any handler(s) * registered via `socket.on('disconnect', ...)` without clearing any * listeners, mirroring how a real reconnecting socket behaves. */ simulateDisconnect(): void; /** Simulates socket.io-client's automatic reconnect of the *same* * instance after a transient disconnect: marks the socket connected again * and fires any handler(s) registered via `socket.on('connect', ...)`, * without clearing or replacing any listeners. A real reconnect is assigned * a fresh `Socket.id`; pass `nextId` to model that new connection identity * (defaults to the current id so existing callers are unaffected). */ simulateReconnect(nextId?: string): void; } { const listeners = new Map void>>(); const emitted: EmittedEvent[] = []; // Internal storage is intentionally keyed loosely (the per-event handler shape // varies by K, which a single Map can't express); the generic signatures on the // exported `socket`/`serverEmit` above and below are what keep test call sites // type-checked against ServerToClientEvents/ClientToServerEvents. const socket = { connected: false, id: 'socket-a', connect: vi.fn(function connect(this: void) { socket.connected = true; return socket; }), on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) { if (!listeners.has(event)) listeners.set(event, new Set()); listeners.get(event)?.add(handler); return socket; }), off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) { listeners.get(event)?.delete(handler); return socket; }), emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) { emitted.push({ event, payload } as EmittedEvent); return socket; }), } as unknown as FakeChatSocket; function serverEmit( event: K, payload: Parameters[0], ): void { for (const handler of listeners.get(event) ?? []) { (handler as (payload: Parameters[0]) => void)(payload); } } function serverEmitRaw(event: ServerEvent, payload: unknown): void { for (const handler of listeners.get(event) ?? []) { (handler as (payload: unknown) => void)(payload); } } function simulateDisconnect(): void { socket.connected = false; const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent; for (const handler of listeners.get(lifecycleKey) ?? []) { (handler as () => void)(); } } function simulateReconnect(nextId: string = socket.id): void { socket.connected = true; socket.id = nextId; const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent; for (const handler of listeners.get(lifecycleKey) ?? []) { (handler as () => void)(); } } return { socket, listeners, emitted, serverEmit, serverEmitRaw, simulateDisconnect, simulateReconnect, }; }