- Add missing 'offline' to AgentStatus union type (types/index.ts) - Add max-retry circuit breaker to useSSE; error state is now reachable - Wire typed SSE payloads (SSEPayloadMap discriminated union) into useRealtimeSync - Add Vitest + 20 unit tests: useSSE lifecycle, back-off, circuit breaker, event parsing, cleanup; useRealtimeSync event-to-invalidation mapping - Rebase on dev to remove stale CUB-119 legacy-deletion commit and align CI workflow (dev already consolidated into single dev.yml) - Tests: npm test → 20/20 pass; Build: npm run build → 0 errors
130 lines
4.1 KiB
TypeScript
130 lines
4.1 KiB
TypeScript
/**
|
|
* Tests for useRealtimeSync — event → query invalidation mapping.
|
|
*
|
|
* Uses .tsx extension so Vite/OXC can parse JSX in the wrapper component.
|
|
*/
|
|
import { renderHook } from '@testing-library/react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
import * as useSSEModule from './useSSE'
|
|
import { useRealtimeSync } from './useRealtimeSync'
|
|
import React from 'react'
|
|
import type { SSEMessage } from '../services/sse'
|
|
|
|
describe('useRealtimeSync', () => {
|
|
let queryClient: QueryClient
|
|
let mockSSEOnMessage: ((msg: { type: string; data: unknown }) => void) | null = null
|
|
|
|
beforeEach(() => {
|
|
queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
})
|
|
mockSSEOnMessage = null
|
|
|
|
// Spy on useSSE to capture the onMessage callback
|
|
vi.spyOn(useSSEModule, 'useSSE').mockImplementation((opts) => {
|
|
mockSSEOnMessage = opts?.onMessage ?? null
|
|
return { status: 'connected' }
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
function render() {
|
|
return renderHook(() => useRealtimeSync(), {
|
|
wrapper: ({ children }: { children: React.ReactNode }) => (
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
|
),
|
|
})
|
|
}
|
|
|
|
it('invalidates ["agents"] on agent.status event', async () => {
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
|
render()
|
|
|
|
const msg: SSEMessage = {
|
|
type: 'agent.status',
|
|
data: { agentId: 'a1', status: 'active' },
|
|
}
|
|
mockSSEOnMessage!(msg)
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] })
|
|
expect(invalidateSpy).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('invalidates ["tasks"] and ["agents"] on agent.task event', async () => {
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
|
render()
|
|
|
|
const msg: SSEMessage = {
|
|
type: 'agent.task',
|
|
data: { agentId: 'a1', taskId: 't1', title: 'Test', action: 'assigned' },
|
|
}
|
|
mockSSEOnMessage!(msg)
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] })
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] })
|
|
expect(invalidateSpy).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('invalidates ["tasks"] and ["agents"] on agent.progress event', async () => {
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
|
render()
|
|
|
|
const msg: SSEMessage = {
|
|
type: 'agent.progress',
|
|
data: { agentId: 'a1', taskId: 't1', progress: 50, message: 'working' },
|
|
}
|
|
mockSSEOnMessage!(msg)
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] })
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] })
|
|
expect(invalidateSpy).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('invalidates ["agents"], ["sessions"], ["tasks"] on fleet.update event', async () => {
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
|
render()
|
|
|
|
const msg: SSEMessage = {
|
|
type: 'fleet.update',
|
|
data: { timestamp: '2026-05-20T12:00:00Z', agentCount: 5 },
|
|
}
|
|
mockSSEOnMessage!(msg)
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] })
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['sessions'] })
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] })
|
|
expect(invalidateSpy).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('does nothing on connected event', async () => {
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
|
render()
|
|
|
|
const msg: SSEMessage = {
|
|
type: 'connected',
|
|
data: { clientCount: 1 },
|
|
}
|
|
mockSSEOnMessage!(msg)
|
|
|
|
expect(invalidateSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does nothing on unknown event types', async () => {
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
|
render()
|
|
|
|
mockSSEOnMessage!({ type: 'unknown.event', data: {} })
|
|
|
|
expect(invalidateSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns sseStatus from useSSE', () => {
|
|
const { result } = render()
|
|
expect(result.current.sseStatus).toBe('connected')
|
|
})
|
|
})
|