@onebun/core 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Unit tests for ws-client.ts
3
+ */
4
+
5
+ import {
6
+ describe,
7
+ it,
8
+ expect,
9
+ beforeEach,
10
+ afterEach,
11
+ mock,
12
+ } from 'bun:test';
13
+
14
+ import type { WsServiceDefinition } from './ws-service-definition';
15
+
16
+ import { createWsClient } from './ws-client';
17
+ import { WsConnectionState } from './ws-client.types';
18
+ import { WsHandlerType } from './ws.types';
19
+
20
+ // Mock WebSocket
21
+ class MockWebSocket {
22
+ static instances: MockWebSocket[] = [];
23
+
24
+ readyState = 1; // OPEN
25
+ onopen: ((event: Event) => void) | null = null;
26
+ onmessage: ((event: MessageEvent) => void) | null = null;
27
+ onclose: ((event: CloseEvent) => void) | null = null;
28
+ onerror: ((event: Event) => void) | null = null;
29
+
30
+ sentMessages: string[] = [];
31
+
32
+ constructor(public url: string) {
33
+ MockWebSocket.instances.push(this);
34
+ // Simulate async open
35
+ setTimeout(() => {
36
+ this.onopen?.(new Event('open'));
37
+ }, 0);
38
+ }
39
+
40
+ send(data: string): void {
41
+ this.sentMessages.push(data);
42
+ }
43
+
44
+ close(code?: number, reason?: string): void {
45
+ this.onclose?.(new CloseEvent('close', { code: code || 1000, reason: reason || '' }));
46
+ }
47
+
48
+ // Simulate receiving a message
49
+ receiveMessage(data: string): void {
50
+ this.onmessage?.(new MessageEvent('message', { data }));
51
+ }
52
+
53
+ // Simulate error
54
+ triggerError(): void {
55
+ this.onerror?.(new Event('error'));
56
+ }
57
+
58
+ static reset(): void {
59
+ MockWebSocket.instances = [];
60
+ }
61
+
62
+ static getLastInstance(): MockWebSocket | undefined {
63
+ return MockWebSocket.instances[MockWebSocket.instances.length - 1];
64
+ }
65
+ }
66
+
67
+ // Mock service definition
68
+ function createMockDefinition(): WsServiceDefinition {
69
+ return {
70
+ _module: class TestModule {},
71
+ _endpoints: [],
72
+ _gateways: new Map([
73
+ ['TestGateway', {
74
+ name: 'TestGateway',
75
+ path: '/ws',
76
+ namespace: undefined,
77
+ events: new Map([
78
+ ['test:event', {
79
+ gateway: 'TestGateway',
80
+ event: 'test:event',
81
+ handler: 'handleTestEvent',
82
+ type: WsHandlerType.MESSAGE,
83
+ }],
84
+ ['chat:*', {
85
+ gateway: 'TestGateway',
86
+ event: 'chat:*',
87
+ handler: 'handleChat',
88
+ type: WsHandlerType.MESSAGE,
89
+ }],
90
+ ]),
91
+ }],
92
+ ]),
93
+ };
94
+ }
95
+
96
+ describe('WsClient', () => {
97
+ let originalWebSocket: typeof globalThis.WebSocket;
98
+
99
+ beforeEach(() => {
100
+ MockWebSocket.reset();
101
+ originalWebSocket = globalThis.WebSocket;
102
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
+ globalThis.WebSocket = MockWebSocket as any;
104
+ });
105
+
106
+ afterEach(() => {
107
+ globalThis.WebSocket = originalWebSocket;
108
+ MockWebSocket.reset();
109
+ });
110
+
111
+ describe('createWsClient', () => {
112
+ it('should create client with default options', () => {
113
+ const definition = createMockDefinition();
114
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
115
+
116
+ expect(client).toBeDefined();
117
+ expect(typeof client.connect).toBe('function');
118
+ expect(typeof client.disconnect).toBe('function');
119
+ expect(typeof client.isConnected).toBe('function');
120
+ expect(typeof client.getState).toBe('function');
121
+ });
122
+ });
123
+
124
+ describe('connect', () => {
125
+ it('should connect to WebSocket server', async () => {
126
+ const definition = createMockDefinition();
127
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
128
+
129
+ await client.connect();
130
+
131
+ expect(client.isConnected()).toBe(true);
132
+ expect(client.getState()).toBe(WsConnectionState.CONNECTED);
133
+ });
134
+
135
+ it('should include auth token in URL', async () => {
136
+ const definition = createMockDefinition();
137
+ const client = createWsClient(definition, {
138
+ url: 'ws://localhost:3000',
139
+ auth: { token: 'test-token' },
140
+ });
141
+
142
+ await client.connect();
143
+
144
+ const ws = MockWebSocket.getLastInstance();
145
+ expect(ws?.url).toContain('token=test-token');
146
+ });
147
+
148
+ it('should include namespace in URL', async () => {
149
+ const definition = createMockDefinition();
150
+ const client = createWsClient(definition, {
151
+ url: 'ws://localhost:3000',
152
+ namespace: 'chat',
153
+ });
154
+
155
+ await client.connect();
156
+
157
+ const ws = MockWebSocket.getLastInstance();
158
+ expect(ws?.url).toContain('namespace=chat');
159
+ });
160
+
161
+ it('should return immediately if already connected', async () => {
162
+ const definition = createMockDefinition();
163
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
164
+
165
+ await client.connect();
166
+ const instanceCount = MockWebSocket.instances.length;
167
+
168
+ await client.connect();
169
+
170
+ // Should not create a new WebSocket
171
+ expect(MockWebSocket.instances.length).toBe(instanceCount);
172
+ });
173
+
174
+ it('should emit connect event', async () => {
175
+ const definition = createMockDefinition();
176
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
177
+ const connectHandler = mock(() => undefined);
178
+
179
+ client.on('connect', connectHandler);
180
+ await client.connect();
181
+
182
+ expect(connectHandler).toHaveBeenCalledTimes(1);
183
+ });
184
+ });
185
+
186
+ describe('disconnect', () => {
187
+ it('should disconnect from server', async () => {
188
+ const definition = createMockDefinition();
189
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
190
+
191
+ await client.connect();
192
+ client.disconnect();
193
+
194
+ expect(client.isConnected()).toBe(false);
195
+ expect(client.getState()).toBe(WsConnectionState.DISCONNECTED);
196
+ });
197
+
198
+ it('should emit disconnect event', async () => {
199
+ const definition = createMockDefinition();
200
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
201
+ const disconnectHandler = mock(() => undefined);
202
+
203
+ await client.connect();
204
+ client.on('disconnect', disconnectHandler);
205
+ client.disconnect();
206
+
207
+ expect(disconnectHandler).toHaveBeenCalled();
208
+ });
209
+ });
210
+
211
+ describe('event listeners', () => {
212
+ it('should subscribe to client events', async () => {
213
+ const definition = createMockDefinition();
214
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
215
+ const handler = mock(() => undefined);
216
+
217
+ client.on('connect', handler);
218
+ await client.connect();
219
+
220
+ expect(handler).toHaveBeenCalled();
221
+ });
222
+
223
+ it('should unsubscribe specific listener', async () => {
224
+ const definition = createMockDefinition();
225
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
226
+ const handler = mock(() => undefined);
227
+
228
+ client.on('connect', handler);
229
+ client.off('connect', handler);
230
+ await client.connect();
231
+
232
+ expect(handler).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it('should unsubscribe all listeners for event', async () => {
236
+ const definition = createMockDefinition();
237
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
238
+ const handler1 = mock(() => undefined);
239
+ const handler2 = mock(() => undefined);
240
+
241
+ client.on('connect', handler1);
242
+ client.on('connect', handler2);
243
+ client.off('connect');
244
+ await client.connect();
245
+
246
+ expect(handler1).not.toHaveBeenCalled();
247
+ expect(handler2).not.toHaveBeenCalled();
248
+ });
249
+ });
250
+
251
+ describe('gateway access', () => {
252
+ it('should access gateway client through proxy', async () => {
253
+ const definition = createMockDefinition();
254
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
255
+
256
+ await client.connect();
257
+
258
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
259
+ const gateway = (client as any).TestGateway;
260
+ expect(gateway).toBeDefined();
261
+ expect(gateway.emit).toBeFunction();
262
+ expect(gateway.send).toBeFunction();
263
+ expect(gateway.on).toBeFunction();
264
+ expect(gateway.off).toBeFunction();
265
+ });
266
+
267
+ it('should cache gateway client', async () => {
268
+ const definition = createMockDefinition();
269
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
270
+
271
+ await client.connect();
272
+
273
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
274
+ const gateway1 = (client as any).TestGateway;
275
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
+ const gateway2 = (client as any).TestGateway;
277
+
278
+ expect(gateway1).toBe(gateway2);
279
+ });
280
+ });
281
+
282
+ describe('message handling', () => {
283
+ it('should handle native format messages', async () => {
284
+ const definition = createMockDefinition();
285
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
286
+ const handler = mock(() => undefined);
287
+
288
+ await client.connect();
289
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
290
+ (client as any).TestGateway.on('test:event', handler);
291
+
292
+ const ws = MockWebSocket.getLastInstance();
293
+ ws?.receiveMessage(JSON.stringify({ event: 'test:event', data: { foo: 'bar' } }));
294
+
295
+ expect(handler).toHaveBeenCalledWith({ foo: 'bar' });
296
+ });
297
+
298
+ it('should match pattern events', async () => {
299
+ const definition = createMockDefinition();
300
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
301
+ const handler = mock(() => undefined);
302
+
303
+ await client.connect();
304
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
305
+ (client as any).TestGateway.on('chat:*', handler);
306
+
307
+ const ws = MockWebSocket.getLastInstance();
308
+ ws?.receiveMessage(JSON.stringify({ event: 'chat:general', data: { text: 'hello' } }));
309
+
310
+ expect(handler).toHaveBeenCalled();
311
+ });
312
+
313
+ it('should handle Engine.IO PING packet', async () => {
314
+ const definition = createMockDefinition();
315
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
316
+
317
+ await client.connect();
318
+
319
+ const ws = MockWebSocket.getLastInstance();
320
+ if (ws) {
321
+ ws.sentMessages.length = 0; // Clear previous messages
322
+
323
+ // Send PING (Engine.IO packet type 2)
324
+ ws.receiveMessage('2');
325
+ }
326
+
327
+ // Should respond with PONG (Engine.IO packet type 3)
328
+ expect(ws?.sentMessages).toContain('3');
329
+ });
330
+ });
331
+
332
+ describe('send and emit', () => {
333
+ it('should send message without acknowledgement', async () => {
334
+ const definition = createMockDefinition();
335
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
336
+
337
+ await client.connect();
338
+
339
+ const ws = MockWebSocket.getLastInstance();
340
+ if (ws) {
341
+ ws.sentMessages.length = 0;
342
+
343
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
344
+ (client as any).TestGateway.send('test:event', { foo: 'bar' });
345
+
346
+ expect(ws.sentMessages.length).toBe(1);
347
+ const sent = JSON.parse(ws.sentMessages[0]);
348
+ expect(sent.event).toBe('test:event');
349
+ expect(sent.data).toEqual({ foo: 'bar' });
350
+ }
351
+ });
352
+
353
+ it('should throw when sending while disconnected', async () => {
354
+ const definition = createMockDefinition();
355
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
356
+
357
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
358
+ expect(() => (client as any).TestGateway?.send?.('test:event', {})).toThrow();
359
+ });
360
+
361
+ it('should emit message and wait for acknowledgement', async () => {
362
+ const definition = createMockDefinition();
363
+ const client = createWsClient(definition, {
364
+ url: 'ws://localhost:3000',
365
+ timeout: 1000,
366
+ });
367
+
368
+ await client.connect();
369
+
370
+ const ws = MockWebSocket.getLastInstance();
371
+
372
+ // Start emit (returns promise)
373
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
374
+ const emitPromise = (client as any).TestGateway.emit('test:event', { foo: 'bar' });
375
+
376
+ // Simulate server acknowledgement
377
+ await new Promise(resolve => setTimeout(resolve, 10));
378
+ const sent = JSON.parse(ws!.sentMessages[ws!.sentMessages.length - 1]);
379
+ ws?.receiveMessage(JSON.stringify({
380
+ event: 'ack',
381
+ data: { result: 'ok' },
382
+ ack: sent.ack,
383
+ }));
384
+
385
+ const result = await emitPromise;
386
+ expect(result).toEqual({ result: 'ok' });
387
+ });
388
+
389
+ it('should timeout emit if no acknowledgement', async () => {
390
+ const definition = createMockDefinition();
391
+ const client = createWsClient(definition, {
392
+ url: 'ws://localhost:3000',
393
+ timeout: 50, // Short timeout for test
394
+ });
395
+
396
+ await client.connect();
397
+
398
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
399
+ const emitPromise = (client as any).TestGateway.emit('test:event', { foo: 'bar' });
400
+
401
+ await expect(emitPromise).rejects.toThrow('Request timeout');
402
+ });
403
+ });
404
+
405
+ describe('reconnection', () => {
406
+ it('should attempt reconnection on disconnect', async () => {
407
+ const definition = createMockDefinition();
408
+ const client = createWsClient(definition, {
409
+ url: 'ws://localhost:3000',
410
+ reconnect: true,
411
+ reconnectInterval: 10,
412
+ maxReconnectAttempts: 3,
413
+ });
414
+ const reconnectAttemptHandler = mock(() => undefined);
415
+
416
+ await client.connect();
417
+ client.on('reconnect_attempt', reconnectAttemptHandler);
418
+
419
+ // Simulate server disconnect
420
+ const ws = MockWebSocket.getLastInstance();
421
+ ws?.close(1006, 'Connection lost');
422
+
423
+ // Wait for reconnect attempt
424
+ await new Promise(resolve => setTimeout(resolve, 50));
425
+
426
+ expect(reconnectAttemptHandler).toHaveBeenCalled();
427
+ });
428
+
429
+ it('should track reconnect attempts', async () => {
430
+ const definition = createMockDefinition();
431
+ const client = createWsClient(definition, {
432
+ url: 'ws://localhost:3000',
433
+ reconnect: true,
434
+ reconnectInterval: 10,
435
+ maxReconnectAttempts: 3,
436
+ });
437
+ let reconnectAttemptCount = 0;
438
+
439
+ await client.connect();
440
+ client.on('reconnect_attempt', (attempt) => {
441
+ reconnectAttemptCount = attempt;
442
+ });
443
+
444
+ // Simulate initial disconnect
445
+ const ws = MockWebSocket.getLastInstance();
446
+ ws?.close(1006, 'Connection lost');
447
+
448
+ // Wait for first reconnect attempt
449
+ await new Promise(resolve => setTimeout(resolve, 50));
450
+
451
+ expect(reconnectAttemptCount).toBeGreaterThanOrEqual(1);
452
+ });
453
+
454
+ it('should not reconnect if reconnect is disabled', async () => {
455
+ const definition = createMockDefinition();
456
+ const client = createWsClient(definition, {
457
+ url: 'ws://localhost:3000',
458
+ reconnect: false,
459
+ });
460
+ const reconnectAttemptHandler = mock(() => undefined);
461
+
462
+ await client.connect();
463
+ client.on('reconnect_attempt', reconnectAttemptHandler);
464
+
465
+ const initialInstanceCount = MockWebSocket.instances.length;
466
+
467
+ // Simulate disconnect
468
+ const ws = MockWebSocket.getLastInstance();
469
+ ws?.close(1006, 'Connection lost');
470
+
471
+ await new Promise(resolve => setTimeout(resolve, 50));
472
+
473
+ expect(reconnectAttemptHandler).not.toHaveBeenCalled();
474
+ expect(MockWebSocket.instances.length).toBe(initialInstanceCount);
475
+ });
476
+ });
477
+
478
+ describe('connection state', () => {
479
+ it('should track connection state', async () => {
480
+ const definition = createMockDefinition();
481
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
482
+
483
+ expect(client.getState()).toBe(WsConnectionState.DISCONNECTED);
484
+
485
+ const connectPromise = client.connect();
486
+ // State changes to CONNECTING synchronously
487
+
488
+ await connectPromise;
489
+ expect(client.getState()).toBe(WsConnectionState.CONNECTED);
490
+
491
+ client.disconnect();
492
+ expect(client.getState()).toBe(WsConnectionState.DISCONNECTED);
493
+ });
494
+ });
495
+
496
+ describe('error handling', () => {
497
+ it('should emit error event on WebSocket error', async () => {
498
+ const definition = createMockDefinition();
499
+ const client = createWsClient(definition, { url: 'ws://localhost:3000' });
500
+ const errorHandler = mock(() => undefined);
501
+
502
+ await client.connect();
503
+ client.on('error', errorHandler);
504
+
505
+ const ws = MockWebSocket.getLastInstance();
506
+ ws?.triggerError();
507
+
508
+ expect(errorHandler).toHaveBeenCalled();
509
+ });
510
+ });
511
+ });