diff --git a/src/lib/__tests__/gateway.test.ts b/src/lib/__tests__/gateway.test.ts new file mode 100644 index 0000000..960ca85 --- /dev/null +++ b/src/lib/__tests__/gateway.test.ts @@ -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 }> = []; + 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'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 0af00f2..97c86f4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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',