import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const clientMocks = vi.hoisted(() => ({ baseUrl: 'http://127.0.0.1:3456', authToken: null as string | null, })) vi.mock('./client', () => ({ getBaseUrl: () => clientMocks.baseUrl, getAuthToken: () => clientMocks.authToken, })) import { buildSessionWebSocketUrl, wsManager } from './websocket' type SocketHandler = (() => void) | ((event: { data: string }) => void) class FakeWebSocket { static readonly CONNECTING = 0 static readonly OPEN = 1 static readonly CLOSING = 2 static readonly CLOSED = 3 static instances: FakeWebSocket[] = [] readonly url: string readyState = FakeWebSocket.CONNECTING onopen: SocketHandler | null = null onmessage: SocketHandler | null = null onclose: SocketHandler | null = null onerror: SocketHandler | null = null sent: string[] = [] constructor(url: string) { this.url = url FakeWebSocket.instances.push(this) } send(data: string) { this.sent.push(data) } close() { this.readyState = FakeWebSocket.CLOSED ;(this.onclose as (() => void) | null)?.() } open() { this.readyState = FakeWebSocket.OPEN ;(this.onopen as (() => void) | null)?.() } fail() { this.readyState = FakeWebSocket.CLOSED ;(this.onclose as (() => void) | null)?.() } } describe('wsManager reconnect buffering', () => { const originalWebSocket = globalThis.WebSocket beforeEach(() => { vi.useFakeTimers() clientMocks.baseUrl = 'http://127.0.0.1:3456' clientMocks.authToken = null FakeWebSocket.instances = [] globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket wsManager.disconnectAll() }) afterEach(() => { wsManager.disconnectAll() globalThis.WebSocket = originalWebSocket vi.useRealTimers() }) it('replays queued messages after an unexpected reconnect', async () => { wsManager.connect('session-reconnect') const firstSocket = FakeWebSocket.instances[0] expect(firstSocket?.url).toContain('/ws/session-reconnect') firstSocket!.open() wsManager.send('session-reconnect', { type: 'user_message', content: 'first' }) expect(firstSocket!.sent).toEqual([ JSON.stringify({ type: 'user_message', content: 'first' }), ]) firstSocket!.fail() wsManager.send('session-reconnect', { type: 'user_message', content: 'queued while offline' }) await vi.advanceTimersByTimeAsync(1000) const secondSocket = FakeWebSocket.instances[1] expect(secondSocket).toBeDefined() secondSocket!.open() expect(secondSocket!.sent).toEqual([ JSON.stringify({ type: 'user_message', content: 'queued while offline' }), ]) }) it('builds websocket URLs from http and encodes token query params', () => { clientMocks.baseUrl = 'http://10.0.0.2:3456' clientMocks.authToken = 'h5 token/with?chars' expect(buildSessionWebSocketUrl('session-reconnect')).toBe( 'ws://10.0.0.2:3456/ws/session-reconnect?token=h5+token%2Fwith%3Fchars', ) }) it('upgrades https backends to wss', () => { clientMocks.baseUrl = 'https://remote.example.com' expect(buildSessionWebSocketUrl('secure-session')).toBe( 'wss://remote.example.com/ws/secure-session', ) }) it('preserves reverse-proxy subpaths when building websocket URLs', () => { clientMocks.baseUrl = 'https://public.example.com/app' expect(buildSessionWebSocketUrl('s1')).toBe( 'wss://public.example.com/app/ws/s1', ) }) })