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'
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify('0.0.0-test'),
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
|
||||
Reference in New Issue
Block a user