test: add GatewayClient WebSocket unit tests (12 tests)
Cover core networking: connect/disconnect, challenge handshake, event routing, request/response, error handling, timeout, reconnect behavior, and credential updates. Adds __APP_VERSION__ define to vitest.config.ts for test env. Total test count: 95 → 107.
This commit is contained in:
274
src/lib/__tests__/gateway.test.ts
Normal file
274
src/lib/__tests__/gateway.test.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { GatewayClient } from '../gateway';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Minimal WebSocket mock */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type WSListener = (ev: { data: string } | { code: number; reason: string } | Event) => void;
|
||||||
|
|
||||||
|
class MockWebSocket {
|
||||||
|
static OPEN = 1;
|
||||||
|
static CLOSED = 3;
|
||||||
|
static instances: MockWebSocket[] = [];
|
||||||
|
|
||||||
|
readyState = MockWebSocket.OPEN;
|
||||||
|
onopen: WSListener | null = null;
|
||||||
|
onmessage: WSListener | null = null;
|
||||||
|
onclose: WSListener | null = null;
|
||||||
|
onerror: WSListener | null = null;
|
||||||
|
sent: string[] = [];
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
MockWebSocket.instances.push(this);
|
||||||
|
// Simulate async open
|
||||||
|
setTimeout(() => this.onopen?.({} as Event), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: string) { this.sent.push(data); }
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.readyState = MockWebSocket.CLOSED;
|
||||||
|
this.onclose?.({ code: 1000, reason: 'normal' } as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper: simulate server sending a message */
|
||||||
|
_receive(data: unknown) {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(data) } as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
static reset() { MockWebSocket.instances = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch global
|
||||||
|
const originalWS = globalThis.WebSocket;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
MockWebSocket.reset();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).WebSocket = MockWebSocket;
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Close any lingering mock WebSockets to prevent leaked timers
|
||||||
|
for (const ws of MockWebSocket.instances) {
|
||||||
|
if (ws.readyState !== MockWebSocket.CLOSED) {
|
||||||
|
ws.readyState = MockWebSocket.CLOSED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.clearAllTimers();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).WebSocket = originalWS;
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Tests */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
describe('GatewayClient', () => {
|
||||||
|
it('initialises with default URL when none provided', () => {
|
||||||
|
// GatewayClient falls back to window.location.hostname — mock it for Node env
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).window = { location: { hostname: 'localhost' } };
|
||||||
|
const gw = new GatewayClient();
|
||||||
|
expect(gw.isConnected).toBe(false);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
delete (globalThis as any).window;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connects and handles challenge → connect flow', async () => {
|
||||||
|
const gw = new GatewayClient('ws://test:1234', 'tok123');
|
||||||
|
const statuses: string[] = [];
|
||||||
|
gw.onStatus(s => statuses.push(s));
|
||||||
|
|
||||||
|
gw.connect();
|
||||||
|
expect(statuses).toContain('connecting');
|
||||||
|
|
||||||
|
// Let the setTimeout in MockWebSocket fire (onopen)
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
|
||||||
|
// Server sends challenge
|
||||||
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||||
|
|
||||||
|
// Client should have sent a connect request
|
||||||
|
expect(ws.sent.length).toBe(1);
|
||||||
|
const req = JSON.parse(ws.sent[0]!);
|
||||||
|
expect(req.method).toBe('connect');
|
||||||
|
expect(req.params.auth.token).toBe('tok123');
|
||||||
|
|
||||||
|
// Server responds ok
|
||||||
|
ws._receive({ type: 'res', id: req.id, ok: true, payload: { session: 'abc' } });
|
||||||
|
|
||||||
|
// Allow microtasks
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(gw.isConnected).toBe(true);
|
||||||
|
expect(statuses).toContain('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects cleanly', async () => {
|
||||||
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||||
|
const statuses: string[] = [];
|
||||||
|
gw.onStatus(s => statuses.push(s));
|
||||||
|
|
||||||
|
gw.connect();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
gw.disconnect();
|
||||||
|
expect(gw.isConnected).toBe(false);
|
||||||
|
expect(statuses[statuses.length - 1]).toBe('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes events to registered handlers', async () => {
|
||||||
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||||
|
const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
||||||
|
gw.onEvent((event, payload) => events.push({ event, payload }));
|
||||||
|
|
||||||
|
gw.connect();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws._receive({ type: 'event', event: 'chat.message', payload: { text: 'hello' } });
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0]!.event).toBe('chat.message');
|
||||||
|
expect(events[0]!.payload.text).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribes event handler when disposer is called', async () => {
|
||||||
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||||
|
const events: string[] = [];
|
||||||
|
const unsub = gw.onEvent((event) => events.push(event));
|
||||||
|
|
||||||
|
gw.connect();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws._receive({ type: 'event', event: 'first' });
|
||||||
|
unsub();
|
||||||
|
ws._receive({ type: 'event', event: 'second' });
|
||||||
|
|
||||||
|
expect(events).toEqual(['first']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves send() promise on success response', async () => {
|
||||||
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||||
|
gw.connect();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
// Complete the challenge first
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||||
|
const connectReq = JSON.parse(ws.sent[0]!);
|
||||||
|
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
const promise = gw.send('sessions.list', { limit: 10 });
|
||||||
|
const sendReq = JSON.parse(ws.sent[1]!);
|
||||||
|
ws._receive({ type: 'res', id: sendReq.id, ok: true, payload: { sessions: [] } });
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result).toEqual({ sessions: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects send() promise on error response', async () => {
|
||||||
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||||
|
gw.connect();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||||
|
const connectReq = JSON.parse(ws.sent[0]!);
|
||||||
|
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
const promise = gw.send('bad.method', {});
|
||||||
|
const sendReq = JSON.parse(ws.sent[1]!);
|
||||||
|
ws._receive({ type: 'res', id: sendReq.id, ok: false, error: 'not found' });
|
||||||
|
|
||||||
|
await expect(promise).rejects.toBe('not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects send() when not connected', async () => {
|
||||||
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||||
|
await expect(gw.send('foo', {})).rejects.toThrow('not connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('times out pending requests after 30s', async () => {
|
||||||
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||||
|
gw.connect();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||||
|
const connectReq = JSON.parse(ws.sent[0]!);
|
||||||
|
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
const promise = gw.send('slow.method', {});
|
||||||
|
|
||||||
|
// Attach rejection handler BEFORE advancing timers to avoid unhandled rejection
|
||||||
|
const rejection = promise.catch((e: Error) => e);
|
||||||
|
|
||||||
|
// Advance past the 30s timeout
|
||||||
|
await vi.advanceTimersByTimeAsync(31000);
|
||||||
|
|
||||||
|
const error = await rejection;
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toBe('timeout');
|
||||||
|
|
||||||
|
// Clean up: disconnect to prevent reconnect timers from firing after test
|
||||||
|
gw.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules reconnect on unexpected close', async () => {
|
||||||
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||||
|
const statuses: string[] = [];
|
||||||
|
gw.onStatus(s => statuses.push(s));
|
||||||
|
|
||||||
|
gw.connect();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
// Simulate unexpected close (not from disconnect())
|
||||||
|
ws.readyState = MockWebSocket.CLOSED;
|
||||||
|
ws.onclose?.({ code: 1006, reason: 'abnormal' } as never);
|
||||||
|
|
||||||
|
expect(statuses).toContain('disconnected');
|
||||||
|
|
||||||
|
// After reconnect delay, a new WebSocket should be created
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(MockWebSocket.instances.length).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
// Clean up to prevent leaked timers
|
||||||
|
gw.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reconnect after explicit disconnect()', async () => {
|
||||||
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||||
|
gw.connect();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
gw.disconnect();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(60000);
|
||||||
|
// Only the original WebSocket should exist
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setCredentials updates URL and token', () => {
|
||||||
|
const gw = new GatewayClient('ws://old:1234', 'old-tok');
|
||||||
|
gw.setCredentials('ws://new:5678', 'new-tok');
|
||||||
|
|
||||||
|
// Connect with new credentials
|
||||||
|
gw.connect();
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
expect(ws.url).toBe('ws://new:5678');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify('0.0.0-test'),
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
|||||||
Reference in New Issue
Block a user