import { afterEach, describe, expect, it, vi } from 'vitest'; import { LiveClient, type SocketFactory } from '../../src/liveClient'; import type { MetricQueryRequest } from '../../src/metricClient'; class FakeSocket { readyState = 0; onopen: (() => void) | null = null; onmessage: ((event: { data: unknown }) => void) | null = null; onerror: (() => void) | null = null; onclose: (() => void) | null = null; readonly sent: string[] = []; open(): void { this.readyState = 1; this.onopen?.(); } send(payload: string): void { this.sent.push(payload); } close(): void { this.readyState = 3; this.onclose?.(); } } const request: MetricQueryRequest = { metric: 'host.cpu.utilization', scope: { serverId: 'smoke-host' }, range: { from: '2026-08-10T06:00:00.000Z', to: '2026-08-10T06:05:00.000Z', stepSeconds: 15 }, aggregation: 'avg', }; afterEach(() => vi.useRealTimers()); describe('LiveClient subscription lifecycle', () => { it('reuses one socket when React replaces an equivalent listener inside the release grace', async () => { vi.useFakeTimers(); const sockets: FakeSocket[] = []; const factory = vi.fn(() => { const socket = new FakeSocket(); sockets.push(socket); return socket; }) as unknown as SocketFactory; const client = new LiveClient('/api/v1/live', factory); const first = client.subscribe(request, () => undefined); sockets[0].open(); await Promise.resolve(); first.unsubscribe(); const shifted = { ...request, range: { ...request.range, from: '2026-08-10T06:01:00.000Z', to: '2026-08-10T06:06:00.000Z' } }; const second = client.subscribe(shifted, () => undefined); await vi.advanceTimersByTimeAsync(300); expect(factory).toHaveBeenCalledTimes(1); expect(sockets[0].readyState).toBe(1); expect(sockets[0].sent.filter((payload) => payload.includes('unsubscribe'))).toHaveLength(0); await vi.advanceTimersByTimeAsync(30_000); expect(sockets[0].sent.some((payload) => payload.includes('"type":"ping"'))).toBe(true); second.unsubscribe(); await vi.advanceTimersByTimeAsync(251); expect(sockets[0].readyState).toBe(1); await vi.advanceTimersByTimeAsync(10_000); expect(sockets[0].readyState).toBe(3); }); it('reuses an idle transport while a rotating dashboard loads its next query', async () => { vi.useFakeTimers(); const sockets: FakeSocket[] = []; const factory = vi.fn(() => { const socket = new FakeSocket(); sockets.push(socket); return socket; }) as unknown as SocketFactory; const client = new LiveClient('/api/v1/live', factory); const first = client.subscribe(request, () => undefined); sockets[0].open(); await Promise.resolve(); first.unsubscribe(); // Subscription state is released after 250 ms, but the bounded transport // grace bridges a slower dashboard document fetch. await vi.advanceTimersByTimeAsync(2_000); expect(sockets[0].readyState).toBe(1); const nextRequest = { ...request, metric: 'host.memory.utilization' }; const second = client.subscribe(nextRequest, () => undefined); await Promise.resolve(); expect(factory).toHaveBeenCalledTimes(1); expect(sockets[0].readyState).toBe(1); expect(sockets[0].sent.some((payload) => payload.includes('host.memory.utilization'))).toBe(true); second.unsubscribe(); await vi.advanceTimersByTimeAsync(10_251); expect(sockets[0].readyState).toBe(3); }); it('resubscribes once after reconnect and resumes samples on the bounded subscription', async () => { vi.useFakeTimers(); const sockets: FakeSocket[] = []; const factory = vi.fn(() => { const socket = new FakeSocket(); sockets.push(socket); return socket; }) as unknown as SocketFactory; const events: string[] = []; const client = new LiveClient('/api/v1/live', factory); const subscription = client.subscribe(request, (event) => events.push(event.type)); sockets[0].open(); await Promise.resolve(); expect(sockets[0].sent.filter((payload) => payload.includes('"type":"subscribe"'))).toHaveLength(1); sockets[0].close(); expect(events).toContain('status'); await vi.advanceTimersByTimeAsync(1_000); expect(factory).toHaveBeenCalledTimes(2); sockets[1].open(); await Promise.resolve(); const subscribe = JSON.parse(sockets[1].sent.find((payload) => payload.includes('"type":"subscribe"')) ?? '{}') as { subscriptionId?: string }; expect(sockets[1].sent.filter((payload) => payload.includes('"type":"subscribe"'))).toHaveLength(1); sockets[1].onmessage?.({ data: JSON.stringify({ type: 'samples', subscriptionId: subscribe.subscriptionId, sequence: 1, samples: [{ timestamp: '2026-08-10T06:06:00.000Z', value: 42, labels: {} }] }) }); expect(events.at(-1)).toBe('samples'); expect(sockets).toHaveLength(2); subscription.unsubscribe(); await vi.advanceTimersByTimeAsync(10_251); }); });