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:
Nicolas Varrot
2026-02-13 14:03:16 +00:00
parent 98f273649e
commit 52458b6171
2 changed files with 277 additions and 0 deletions

View 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');
});
});

View File

@@ -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',