@volley/recognition-client-sdk 0.1.200

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,551 @@
1
+ /**
2
+ * Unit tests for RealTimeTwoWayWebSocketRecognitionClient
3
+ */
4
+
5
+ import { RealTimeTwoWayWebSocketRecognitionClient } from './recognition-client';
6
+ import { ClientState } from './recognition-client.types';
7
+ import { WebSocket as MockWebSocket } from 'ws';
8
+
9
+ // Mock WebSocket
10
+ jest.mock('ws');
11
+
12
+ describe('RealTimeTwoWayWebSocketRecognitionClient', () => {
13
+ let client: RealTimeTwoWayWebSocketRecognitionClient;
14
+ let mockWs: any;
15
+
16
+ beforeEach(() => {
17
+ // Reset mocks
18
+ jest.clearAllMocks();
19
+
20
+ // Create mock WebSocket
21
+ mockWs = {
22
+ readyState: MockWebSocket.CONNECTING,
23
+ send: jest.fn(),
24
+ close: jest.fn(),
25
+ on: jest.fn(),
26
+ removeAllListeners: jest.fn(),
27
+ };
28
+
29
+ // Mock WebSocket constructor
30
+ (MockWebSocket as any).mockImplementation(() => mockWs);
31
+
32
+ // Create client
33
+ client = new RealTimeTwoWayWebSocketRecognitionClient({
34
+ url: 'ws://test.example.com/recognize',
35
+ asrRequestConfig: {
36
+ provider: 'deepgram',
37
+ language: 'en',
38
+ sampleRate: 16000,
39
+ encoding: 'linear16'
40
+ }
41
+ });
42
+ });
43
+
44
+ afterEach(() => {
45
+ // Clean up
46
+ });
47
+
48
+ describe('Constructor', () => {
49
+ it('should initialize with correct default values', () => {
50
+ expect(client.getState()).toBe(ClientState.INITIAL);
51
+ expect(client.isConnected()).toBe(false);
52
+ expect(client.isBufferOverflowing()).toBe(false);
53
+ expect(client.getAudioUtteranceId()).toBeDefined();
54
+ expect(typeof client.getAudioUtteranceId()).toBe('string');
55
+ });
56
+
57
+ it('should have immutable audioUtteranceId', () => {
58
+ const originalId = client.getAudioUtteranceId();
59
+ // audioUtteranceId should not change
60
+ expect(client.getAudioUtteranceId()).toBe(originalId);
61
+ });
62
+
63
+ it('should initialize stats correctly', () => {
64
+ const stats = client.getStats();
65
+ expect(stats.audioBytesSent).toBe(0);
66
+ expect(stats.audioChunksSent).toBe(0);
67
+ expect(stats.audioChunksBuffered).toBe(0);
68
+ expect(stats.bufferOverflowCount).toBe(0);
69
+ expect(stats.currentBufferedChunks).toBe(0);
70
+ });
71
+ });
72
+
73
+ describe.skip('State Management', () => {
74
+ it('should transition from INITIAL to CONNECTING on connect()', async () => {
75
+ expect(client.getState()).toBe(ClientState.INITIAL);
76
+
77
+ // Simulate successful connection
78
+ const connectPromise = client.connect();
79
+ expect(client.getState()).toBe(ClientState.CONNECTING);
80
+
81
+ // Simulate WebSocket open event
82
+ mockWs.readyState = MockWebSocket.OPEN;
83
+ const openHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'open')[1];
84
+ openHandler();
85
+
86
+ await connectPromise;
87
+ expect(client.getState()).toBe(ClientState.CONNECTED);
88
+ });
89
+
90
+ it('should transition to READY when server sends ready message', async () => {
91
+ // Connect first
92
+ const connectPromise = client.connect();
93
+ mockWs.readyState = MockWebSocket.OPEN;
94
+ const openHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'open')[1];
95
+ openHandler();
96
+ await connectPromise;
97
+
98
+ // Simulate ready message
99
+ const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1];
100
+ const readyMessage = JSON.stringify({
101
+ v: 1,
102
+ type: 'message',
103
+ data: {
104
+ type: 'ClientControlMessage',
105
+ action: 'ready_for_uploading_recording',
106
+ audioUtteranceId: 'test-utterance-id'
107
+ }
108
+ });
109
+ messageHandler(readyMessage);
110
+
111
+ expect(client.getState()).toBe(ClientState.READY);
112
+ expect(client.isConnected()).toBe(true);
113
+ });
114
+
115
+ it('should transition to STOPPING when stopRecording() is called', async () => {
116
+ // Setup: Connect and become ready
117
+ await setupConnectedClient();
118
+
119
+ // Call stopRecording
120
+ const stopPromise = client.stopRecording();
121
+ expect(client.getState()).toBe(ClientState.STOPPING);
122
+
123
+ // Simulate final transcript
124
+ const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1];
125
+ const finalMessage = JSON.stringify({
126
+ v: 1,
127
+ type: 'message',
128
+ data: {
129
+ type: 'Transcription',
130
+ finalTranscript: 'test',
131
+ is_finished: true
132
+ }
133
+ });
134
+ messageHandler(finalMessage);
135
+
136
+ await stopPromise;
137
+ expect(client.getState()).toBe(ClientState.STOPPED);
138
+ });
139
+
140
+ it('should transition to FAILED on connection error', async () => {
141
+ const connectPromise = client.connect();
142
+
143
+ // Simulate error
144
+ const errorHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'error')[1];
145
+ errorHandler(new Error('Connection failed'));
146
+
147
+ try {
148
+ await connectPromise;
149
+ } catch (e) {
150
+ // Expected
151
+ }
152
+
153
+ expect(client.getState()).toBe(ClientState.FAILED);
154
+ });
155
+ });
156
+
157
+ describe.skip('Connection Handling', () => {
158
+ it('should handle duplicate connect() calls', async () => {
159
+ // Call connect twice
160
+ const promise1 = client.connect();
161
+ const promise2 = client.connect();
162
+
163
+ // Should be the same promise
164
+ expect(promise1).toBe(promise2);
165
+
166
+ // Simulate successful connection
167
+ mockWs.readyState = MockWebSocket.OPEN;
168
+ const openHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'open')[1];
169
+ openHandler();
170
+
171
+ await Promise.all([promise1, promise2]);
172
+ expect(client.getState()).toBe(ClientState.CONNECTED);
173
+ });
174
+
175
+ it('should not reconnect if already connected', async () => {
176
+ // First connection
177
+ await setupConnectedClient();
178
+ const firstWs = mockWs;
179
+
180
+ // Try to connect again
181
+ await client.connect();
182
+
183
+ // Should not create new WebSocket
184
+ expect(MockWebSocket).toHaveBeenCalledTimes(1);
185
+ expect(client.isConnected()).toBe(true);
186
+ });
187
+ });
188
+
189
+ describe.skip('Audio Handling', () => {
190
+ it('should buffer audio when not ready', () => {
191
+ const audioData = Buffer.from([1, 2, 3, 4]);
192
+ client.sendAudio(audioData);
193
+
194
+ const stats = client.getStats();
195
+ expect(stats.audioBytesSent).toBe(0);
196
+ expect(stats.audioChunksBuffered).toBe(1);
197
+ expect(stats.currentBufferedChunks).toBe(1);
198
+ });
199
+
200
+ it('should send audio immediately when ready', async () => {
201
+ await setupReadyClient();
202
+
203
+ const audioData = Buffer.from([1, 2, 3, 4]);
204
+ client.sendAudio(audioData);
205
+
206
+ // Should have sent the audio
207
+ expect(mockWs.send).toHaveBeenCalled();
208
+ const stats = client.getStats();
209
+ expect(stats.audioBytesSent).toBe(4);
210
+ expect(stats.audioChunksSent).toBe(1);
211
+ });
212
+
213
+ it('should flush buffered audio when becoming ready', async () => {
214
+ // Buffer some audio while disconnected
215
+ const audioData1 = Buffer.from([1, 2, 3, 4]);
216
+ const audioData2 = Buffer.from([5, 6, 7, 8]);
217
+ client.sendAudio(audioData1);
218
+ client.sendAudio(audioData2);
219
+
220
+ // Verify buffered
221
+ let stats = client.getStats();
222
+ expect(stats.currentBufferedChunks).toBe(2);
223
+ expect(stats.audioBytesSent).toBe(0);
224
+
225
+ // Connect and become ready
226
+ await setupReadyClient();
227
+
228
+ // Should have flushed the buffer
229
+ stats = client.getStats();
230
+ expect(stats.audioBytesSent).toBe(8);
231
+ expect(stats.audioChunksSent).toBe(2);
232
+ expect(stats.currentBufferedChunks).toBe(0);
233
+ });
234
+
235
+ it('should detect buffer overflow', () => {
236
+ // Fill buffer to capacity (simulate overflow)
237
+ const chunkSize = 1024;
238
+ const maxChunks = 6000; // Default buffer size
239
+
240
+ for (let i = 0; i <= maxChunks; i++) {
241
+ client.sendAudio(Buffer.alloc(chunkSize));
242
+ }
243
+
244
+ expect(client.isBufferOverflowing()).toBe(true);
245
+ const stats = client.getStats();
246
+ expect(stats.bufferOverflowCount).toBeGreaterThan(0);
247
+ });
248
+ });
249
+
250
+ describe.skip('Helper Methods', () => {
251
+ it('should report correct connection status', async () => {
252
+ expect(client.isConnected()).toBe(false);
253
+
254
+ await setupConnectedClient();
255
+ expect(client.isConnected()).toBe(true);
256
+
257
+ await setupReadyClient();
258
+ expect(client.isConnected()).toBe(true);
259
+
260
+ // Disconnect
261
+ const closeHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'close')[1];
262
+ closeHandler(1000, 'Normal closure');
263
+ expect(client.isConnected()).toBe(false);
264
+ });
265
+
266
+ it('should track statistics correctly', async () => {
267
+ await setupReadyClient();
268
+
269
+ // Send some audio
270
+ client.sendAudio(Buffer.alloc(100));
271
+ client.sendAudio(Buffer.alloc(200));
272
+ client.sendAudio(Buffer.alloc(300));
273
+
274
+ const stats = client.getStats();
275
+ expect(stats.audioBytesSent).toBe(600);
276
+ expect(stats.audioChunksSent).toBe(3);
277
+ expect(stats.audioChunksBuffered).toBe(3);
278
+ });
279
+ });
280
+
281
+ describe.skip('Memory Management', () => {
282
+ it('should clear ring buffer on disconnect', async () => {
283
+ // Buffer some audio
284
+ client.sendAudio(Buffer.alloc(100));
285
+ client.sendAudio(Buffer.alloc(200));
286
+
287
+ let stats = client.getStats();
288
+ expect(stats.currentBufferedChunks).toBe(2);
289
+
290
+ // Connect then disconnect
291
+ await setupConnectedClient();
292
+ const closeHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'close')[1];
293
+ closeHandler(1000, 'Normal closure');
294
+
295
+ // Buffer should be cleared
296
+ stats = client.getStats();
297
+ expect(stats.currentBufferedChunks).toBe(0);
298
+ });
299
+
300
+ it('should cleanup WebSocket listeners on close', async () => {
301
+ await setupConnectedClient();
302
+
303
+ await client.stopRecording();
304
+
305
+ expect(mockWs.removeAllListeners).toHaveBeenCalled();
306
+ expect(mockWs.close).toHaveBeenCalled();
307
+ });
308
+ });
309
+
310
+ describe.skip('Message Handling', () => {
311
+ it('should handle transcription messages', async () => {
312
+ const onTranscript = jest.fn();
313
+ client = new RealTimeTwoWayWebSocketRecognitionClient({
314
+ url: 'ws://test.example.com/recognize',
315
+ onTranscript,
316
+ });
317
+
318
+ await setupConnectedClient();
319
+
320
+ const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1];
321
+ const transcriptMessage = JSON.stringify({
322
+ v: 1,
323
+ type: 'message',
324
+ data: {
325
+ type: 'Transcription',
326
+ finalTranscript: 'Hello world',
327
+ finalTranscriptConfidence: 0.95,
328
+ is_finished: false
329
+ }
330
+ });
331
+
332
+ messageHandler(transcriptMessage);
333
+
334
+ expect(onTranscript).toHaveBeenCalledWith(expect.objectContaining({
335
+ finalTranscript: 'Hello world',
336
+ finalTranscriptConfidence: 0.95,
337
+ is_finished: false
338
+ }));
339
+ });
340
+
341
+ it('should handle function call messages', async () => {
342
+ const onFunctionCall = jest.fn();
343
+ client = new RealTimeTwoWayWebSocketRecognitionClient({
344
+ url: 'ws://test.example.com/recognize',
345
+ onFunctionCall,
346
+ });
347
+
348
+ await setupConnectedClient();
349
+
350
+ const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1];
351
+ const functionCallMessage = JSON.stringify({
352
+ v: 1,
353
+ type: 'message',
354
+ data: {
355
+ type: 'FunctionCall',
356
+ audioUtteranceId: 'test-id',
357
+ functionName: 'playMusic',
358
+ functionArgJson: '{"song": "Bohemian Rhapsody"}'
359
+ }
360
+ });
361
+
362
+ messageHandler(functionCallMessage);
363
+
364
+ expect(onFunctionCall).toHaveBeenCalledWith(expect.objectContaining({
365
+ type: 'FunctionCall',
366
+ audioUtteranceId: 'test-id',
367
+ functionName: 'playMusic',
368
+ functionArgJson: '{"song": "Bohemian Rhapsody"}'
369
+ }));
370
+ });
371
+
372
+ it('should handle metadata messages', async () => {
373
+ const onMetadata = jest.fn();
374
+ client = new RealTimeTwoWayWebSocketRecognitionClient({
375
+ url: 'ws://test.example.com/recognize',
376
+ onMetadata,
377
+ });
378
+
379
+ await setupConnectedClient();
380
+
381
+ const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1];
382
+ const metadataMessage = JSON.stringify({
383
+ v: 1,
384
+ type: 'message',
385
+ data: {
386
+ type: 'Metadata',
387
+ audioUtteranceId: 'test-id',
388
+ duration: 1000
389
+ }
390
+ });
391
+
392
+ messageHandler(metadataMessage);
393
+
394
+ expect(onMetadata).toHaveBeenCalledWith(expect.objectContaining({
395
+ audioUtteranceId: 'test-id',
396
+ duration: 1000
397
+ }));
398
+ });
399
+
400
+ it('should handle error messages', async () => {
401
+ const onError = jest.fn();
402
+ client = new RealTimeTwoWayWebSocketRecognitionClient({
403
+ url: 'ws://test.example.com/recognize',
404
+ onError,
405
+ });
406
+
407
+ await setupConnectedClient();
408
+
409
+ const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1];
410
+ const errorMessage = JSON.stringify({
411
+ v: 1,
412
+ type: 'message',
413
+ data: {
414
+ type: 'Error',
415
+ message: 'Something went wrong'
416
+ }
417
+ });
418
+
419
+ messageHandler(errorMessage);
420
+
421
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
422
+ expect(onError.mock.calls[0][0].message).toContain('Something went wrong');
423
+ });
424
+
425
+ it('should handle primitive message data without crashing', async () => {
426
+ const onError = jest.fn();
427
+ client = new RealTimeTwoWayWebSocketRecognitionClient({
428
+ url: 'ws://test.example.com/recognize',
429
+ onError,
430
+ });
431
+
432
+ await setupConnectedClient();
433
+
434
+ const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1];
435
+
436
+ // Test with various primitive values
437
+ const primitiveMessages = [
438
+ JSON.stringify({ v: 1, type: 'test', data: 'string' }),
439
+ JSON.stringify({ v: 1, type: 'test', data: 123 }),
440
+ JSON.stringify({ v: 1, type: 'test', data: true }),
441
+ JSON.stringify({ v: 1, type: 'test', data: null }),
442
+ ];
443
+
444
+ // Should not throw
445
+ expect(() => {
446
+ primitiveMessages.forEach(msg => messageHandler(msg));
447
+ }).not.toThrow();
448
+
449
+ // Should log errors for primitives
450
+ expect(onError).toHaveBeenCalled();
451
+ });
452
+ });
453
+
454
+ describe('Debug Logging', () => {
455
+ it('should not log debug messages when debug logging is disabled (default)', () => {
456
+ const mockLogger = jest.fn();
457
+ const testClient = new RealTimeTwoWayWebSocketRecognitionClient({
458
+ url: 'ws://test.example.com/recognize',
459
+ asrRequestConfig: {
460
+ provider: 'deepgram',
461
+ language: 'en',
462
+ sampleRate: 16000,
463
+ encoding: 'linear16'
464
+ },
465
+ logger: mockLogger
466
+ });
467
+
468
+ // Trigger some actions that would normally log debug messages
469
+ expect(testClient.getState()).toBe(ClientState.INITIAL);
470
+
471
+ // Debug logs should not be called
472
+ const debugCalls = mockLogger.mock.calls.filter(call => call[0] === 'debug');
473
+ expect(debugCalls.length).toBe(0);
474
+
475
+ // But other log levels should work
476
+ const nonDebugCalls = mockLogger.mock.calls.filter(call => call[0] !== 'debug');
477
+ // May or may not have non-debug logs, just checking we can track them
478
+ expect(nonDebugCalls.length).toBeGreaterThanOrEqual(0);
479
+ });
480
+
481
+ it('should log debug messages when enableDebugLog is true in debugCommand', () => {
482
+ const mockLogger = jest.fn();
483
+ const testClient = new RealTimeTwoWayWebSocketRecognitionClient({
484
+ url: 'ws://test.example.com/recognize',
485
+ asrRequestConfig: {
486
+ provider: 'deepgram',
487
+ language: 'en',
488
+ sampleRate: 16000,
489
+ encoding: 'linear16',
490
+ debugCommand: {
491
+ enableDebugLog: true
492
+ }
493
+ } as any, // Using 'as any' to bypass type checking for the new field
494
+ logger: mockLogger
495
+ });
496
+
497
+ // Note: Debug logging is enabled in onConnected() when ASR request is sent
498
+ // So we need to test after connection, but for now we verify the config is accepted
499
+ expect(testClient.getAudioUtteranceId()).toBeDefined();
500
+ });
501
+
502
+ it('should respect debugCommand.enableDebugLog flag', () => {
503
+ const mockLogger = jest.fn();
504
+
505
+ // Client with debug logging explicitly disabled
506
+ const clientNoDebug = new RealTimeTwoWayWebSocketRecognitionClient({
507
+ url: 'ws://test.example.com/recognize',
508
+ asrRequestConfig: {
509
+ provider: 'deepgram',
510
+ language: 'en',
511
+ sampleRate: 16000,
512
+ encoding: 'linear16',
513
+ debugCommand: {
514
+ enableDebugLog: false
515
+ }
516
+ } as any,
517
+ logger: mockLogger
518
+ });
519
+
520
+ expect(clientNoDebug.getAudioUtteranceId()).toBeDefined();
521
+
522
+ // Debug logs should not be called
523
+ const debugCalls = mockLogger.mock.calls.filter(call => call[0] === 'debug');
524
+ expect(debugCalls.length).toBe(0);
525
+ });
526
+ });
527
+
528
+ // Helper functions
529
+ async function setupConnectedClient() {
530
+ const connectPromise = client.connect();
531
+ mockWs.readyState = MockWebSocket.OPEN;
532
+ const openHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'open')[1];
533
+ openHandler();
534
+ await connectPromise;
535
+ }
536
+
537
+ async function setupReadyClient() {
538
+ await setupConnectedClient();
539
+ const messageHandler = mockWs.on.mock.calls.find((call: any[]) => call[0] === 'message')[1];
540
+ const readyMessage = JSON.stringify({
541
+ v: 1,
542
+ type: 'message',
543
+ data: {
544
+ type: 'ClientControlMessage',
545
+ action: 'ready_for_uploading_recording',
546
+ audioUtteranceId: 'test-utterance-id'
547
+ }
548
+ });
549
+ messageHandler(readyMessage);
550
+ }
551
+ });