Files
aiturk-hermes-ide/apps/desktop/src/lib/json-rpc-gateway-heartbeat.test.ts
T

132 lines
3.5 KiB
TypeScript

import { JsonRpcGatewayClient } from '@hermes/shared'
import { afterEach, describe, expect, it, vi } from 'vitest'
interface ListenerEntry {
callback: (event: any) => void
once: boolean
}
class FakeSocket {
static readonly CLOSED = 3
static readonly OPEN = 1
readonly sent: string[] = []
readyState = FakeSocket.OPEN
private listeners = new Map<string, ListenerEntry[]>()
addEventListener(type: string, callback: (event: any) => void, options?: AddEventListenerOptions): void {
const entries = this.listeners.get(type) ?? []
entries.push({ callback, once: Boolean(options?.once) })
this.listeners.set(type, entries)
}
close(): void {
if (this.readyState === FakeSocket.CLOSED) {
return
}
this.readyState = FakeSocket.CLOSED
this.emit('close', { code: 1000 })
}
emit(type: string, event: any = {}): void {
const entries = [...(this.listeners.get(type) ?? [])]
for (const entry of entries) {
entry.callback(event)
if (entry.once) {
this.removeEventListener(type, entry.callback)
}
}
}
message(frame: unknown): void {
this.emit('message', { data: JSON.stringify(frame) })
}
removeEventListener(type: string, callback: (event: any) => void): void {
this.listeners.set(
type,
(this.listeners.get(type) ?? []).filter(entry => entry.callback !== callback)
)
}
send(payload: string): void {
this.sent.push(payload)
}
}
describe('JsonRpcGatewayClient heartbeat recovery', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('invalidates a silently dead advertised socket and ignores its late frames', async () => {
vi.useFakeTimers()
vi.stubGlobal('WebSocket', { OPEN: FakeSocket.OPEN })
const socket = new FakeSocket()
const states: string[] = []
const events: string[] = []
const client = new JsonRpcGatewayClient({
heartbeatDeadlineMs: 45,
heartbeatIntervalMs: 15,
socketFactory: () => socket as unknown as WebSocket
})
client.onState(state => states.push(state))
client.onAny(event => events.push(event.type))
const connected = client.connect('ws://gateway.test/api/ws')
socket.emit('open')
await connected
socket.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'gateway.ready', payload: { heartbeat: true } }
})
await vi.advanceTimersByTimeAsync(61)
expect(client.connectionState).toBe('closed')
expect(socket.readyState).toBe(FakeSocket.CLOSED)
expect(states.at(-1)).toBe('closed')
socket.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'message.complete', payload: { text: 'late duplicate' } }
})
expect(events).toEqual(['gateway.ready'])
})
it('keeps older backends open when gateway.ready does not advertise heartbeat', async () => {
vi.useFakeTimers()
vi.stubGlobal('WebSocket', { OPEN: FakeSocket.OPEN })
const socket = new FakeSocket()
const client = new JsonRpcGatewayClient({
heartbeatDeadlineMs: 45,
heartbeatIntervalMs: 15,
socketFactory: () => socket as unknown as WebSocket
})
const connected = client.connect('ws://gateway.test/api/ws')
socket.emit('open')
await connected
socket.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'gateway.ready', payload: {} }
})
await vi.advanceTimersByTimeAsync(1_000)
expect(client.connectionState).toBe('open')
expect(socket.readyState).toBe(FakeSocket.OPEN)
expect(socket.sent).toEqual([])
})
})