/** * Tests for useSSE — SSE connection lifecycle, back-off, event parsing, and cleanup. * * jsdom does not include EventSource, so we mock it completely. */ import { renderHook, act, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useSSE } from './useSSE' // --------------------------------------------------------------------------- // Mock EventSource — defined as a plain class so `new EventSource()` works // --------------------------------------------------------------------------- class MockEventSource { url: string onopen: (() => void) | null = null onerror: ((evt: Event) => void) | null = null onmessage: ((evt: MessageEvent) => void) | null = null private listeners: Map void>> = new Map() readyState: number = 0 constructor(url: string) { this.url = url } addEventListener(type: string, handler: (evt: Event) => void) { if (!this.listeners.has(type)) this.listeners.set(type, []) this.listeners.get(type)!.push(handler) } removeEventListener() { /* no-op for tests */ } close() { this.readyState = 2 this.onopen = null this.onerror = null this.onmessage = null this.listeners.clear() } // Test helpers _simulateOpen() { this.onopen?.() } _simulateError() { this.onerror?.(new Event('error')) } _simulateNamedEvent(type: string, data: string) { const handlers = this.listeners.get(type) if (handlers) { const evt = new MessageEvent(type, { data }) as Event handlers.forEach((h) => h(evt)) } } _simulateMessage(data: string) { this.onmessage?.(new MessageEvent('message', { data }) as MessageEvent) } static readonly CONNECTING = 0 static readonly OPEN = 1 static readonly CLOSED = 2 } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- let esInstances: MockEventSource[] describe('useSSE', () => { beforeEach(() => { esInstances = [] // Replace global EventSource with our mock class Object.defineProperty(globalThis, 'EventSource', { // The mock must use a class for `new EventSource()` to work value: class extends MockEventSource { constructor(url: string) { super(url) esInstances.push(this) } }, writable: true, configurable: true, }) vi.useFakeTimers({ shouldAdvanceTime: true }) }) afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() }) // ── Initial connection ────────────────────────────────────────────────── it('starts in "connecting" state and creates an EventSource', () => { const { result } = renderHook(() => useSSE({ url: '/api/events' })) expect(result.current.status).toBe('connecting') expect(esInstances.length).toBeGreaterThanOrEqual(1) expect(esInstances[0].url).toBe('/api/events') }) it('transitions to "connected" on open', async () => { const onOpen = vi.fn() const { result } = renderHook(() => useSSE({ url: '/api/events', onOpen })) act(() => { esInstances[0]._simulateOpen() }) await waitFor(() => { expect(result.current.status).toBe('connected') }) expect(onOpen).toHaveBeenCalledTimes(1) }) // ── Reconnection with exponential back-off ────────────────────────────── it('retries after error with exponential back-off', async () => { const { result } = renderHook(() => useSSE({ url: '/api/events', reconnectBaseMs: 1000, reconnectMaxMs: 30000 }), ) // First error → reconnecting, retry at 1s act(() => { esInstances[0]._simulateError() }) await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) expect(esInstances).toHaveLength(1) // Advance 1000ms → second EventSource created act(() => { vi.advanceTimersByTime(1000) }) expect(esInstances).toHaveLength(2) // Second error → reconnecting, retry at 2s act(() => { esInstances[1]._simulateError() }) await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) act(() => { vi.advanceTimersByTime(2000) }) expect(esInstances).toHaveLength(3) // Third error → reconnecting, retry at 4s act(() => { esInstances[2]._simulateError() }) act(() => { vi.advanceTimersByTime(4000) }) expect(esInstances).toHaveLength(4) }) it('caps reconnect delay at reconnectMaxMs', async () => { renderHook(() => useSSE({ url: '/api/events', reconnectBaseMs: 1000, reconnectMaxMs: 10000 }), ) // Force 5 errors to push the exponent past the cap for (let i = 0; i < 5; i++) { act(() => { esInstances[i]._simulateError() }) const expectedDelay = Math.min(1000 * 2 ** i, 10000) act(() => { vi.advanceTimersByTime(expectedDelay) }) } // 6 ES instances created (initial + 5 retries) expect(esInstances).toHaveLength(6) }) // ── Circuit-breaker (max retries) ─────────────────────────────────────── it('transitions to "error" after reconnectLimit is exceeded', async () => { const { result } = renderHook(() => useSSE({ url: '/api/events', reconnectBaseMs: 100, reconnectLimit: 2 }), ) // First error → reconnecting act(() => { esInstances[0]._simulateError() }) await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) // Advance → retry act(() => { vi.advanceTimersByTime(100) }) // Second error → reconnecting (attempt 2, still ≤ limit) act(() => { esInstances[1]._simulateError() }) await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) act(() => { vi.advanceTimersByTime(200) }) // Third error → limit exceeded (3 > 2) → error act(() => { esInstances[2]._simulateError() }) await waitFor(() => { expect(result.current.status).toBe('error') }) }) it('resets reconnect counter on successful connection', async () => { const { result } = renderHook(() => useSSE({ url: '/api/events', reconnectBaseMs: 100, reconnectLimit: 3 }), ) // Two errors then a successful connect act(() => { esInstances[0]._simulateError() }) act(() => { vi.advanceTimersByTime(100) }) act(() => { esInstances[1]._simulateOpen() }) await waitFor(() => { expect(result.current.status).toBe('connected') }) // Now error again — counter should be reset, so we get fresh attempts act(() => { esInstances[1]._simulateError() }) await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) expect(result.current.status).toBe('reconnecting') }) // ── Cleanup on unmount ─────────────────────────────────────────────────── it('closes EventSource on unmount', () => { const closeSpy = vi.spyOn(MockEventSource.prototype, 'close') const { unmount } = renderHook(() => useSSE({ url: '/api/events' })) unmount() expect(closeSpy).toHaveBeenCalled() }) it('does not update state after unmount', async () => { const { result, unmount } = renderHook(() => useSSE({ url: '/api/events' })) unmount() // These should be no-ops after unmount (mountedRef guards) act(() => { esInstances[0]._simulateOpen() }) act(() => { esInstances[0]._simulateError() }) // State should not have changed expect(result.current.status).toBe('connecting') }) // ── Event parsing ─────────────────────────────────────────────────────── it('parses valid JSON data into objects', async () => { const onMessage = vi.fn() renderHook(() => useSSE({ url: '/api/events', onMessage })) act(() => { esInstances[0]._simulateNamedEvent('agent.status', JSON.stringify({ agentId: 'a1', status: 'active' })) }) expect(onMessage).toHaveBeenCalledWith({ type: 'agent.status', data: { agentId: 'a1', status: 'active' }, }) }) it('passes invalid JSON through as raw string', async () => { const onMessage = vi.fn() renderHook(() => useSSE({ url: '/api/events', onMessage })) act(() => { esInstances[0]._simulateNamedEvent('agent.status', 'not valid json {{{') }) expect(onMessage).toHaveBeenCalledWith({ type: 'agent.status', data: 'not valid json {{{', }) }) // ── enabled=false skips connection ────────────────────────────────────── it('does not create EventSource when enabled=false', () => { const { result } = renderHook(() => useSSE({ url: '/api/events', enabled: false })) expect(esInstances).toHaveLength(0) expect(result.current.status).toBe('connecting') }) // ── onError callback ──────────────────────────────────────────────────── it('calls onError on connection failure', async () => { const onError = vi.fn() renderHook(() => useSSE({ url: '/api/events', onError, reconnectBaseMs: 100 }), ) act(() => { esInstances[0]._simulateError() }) expect(onError).toHaveBeenCalledTimes(1) }) // ── Default URL ───────────────────────────────────────────────────────── it('uses /api/events as default URL', () => { renderHook(() => useSSE()) expect(esInstances[0].url).toBe('/api/events') }) })