This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user