268 lines
9.8 KiB
TypeScript
268 lines
9.8 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, Array<(evt: Event) => 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')
|
||
|
|
})
|
||
|
|
})
|