11import { describe , it , expect , beforeEach , afterEach } from "vitest" ;
22import { DeepgramClient } from "../../../src" ;
3+ import type { Deepgram } from "../../../src" ;
34import { mockServerPool } from "../../mock-server/MockServerPool" ;
45import { MockServer } from "../../mock-server/MockServer" ;
5- import { generateMockAudioData , WebSocketEventTracker } from "./helpers" ;
6+ import { generateMockAudioData , WebSocketEventTracker , waitForEventCount } from "./helpers" ;
67import type { Server } from "ws" ;
78
89/**
@@ -17,11 +18,16 @@ import type { Server } from "ws";
1718 * The last point is the key regression guard: the auto-generated V2 socket
1819 * parses every message as JSON; WrappedSpeakV2Socket replaces that with a
1920 * binary-aware handler so Flux audio frames survive.
21+ *
22+ * Server-sent payloads are typed with the real SpeakV2* types so a schema drift
23+ * (renamed/removed field) surfaces as a compile error rather than silently
24+ * passing. Waits use waitForEventCount (not fixed sleeps) to avoid CI flakiness.
2025 */
2126describe ( "Speak v2 (Flux) WebSocket TTS streaming" , ( ) => {
2227 let server : MockServer ;
2328 let wsServer : Server ;
2429 let wsPort : number ;
30+ const openSockets : Array < { close : ( ) => void } > = [ ] ;
2531
2632 beforeEach ( async ( ) => {
2733 server = mockServerPool . createServer ( ) ;
@@ -51,6 +57,16 @@ describe("Speak v2 (Flux) WebSocket TTS streaming", () => {
5157 } ) ;
5258
5359 afterEach ( ( ) => {
60+ // Close any sockets a test created, even if it threw before its own cleanup,
61+ // so leaked ReconnectingWebSockets don't retry against the dead port.
62+ for ( const socket of openSockets ) {
63+ try {
64+ socket . close ( ) ;
65+ } catch {
66+ // already closed
67+ }
68+ }
69+ openSockets . length = 0 ;
5470 wsServer ?. close ( ) ;
5571 } ) ;
5672
@@ -69,6 +85,7 @@ describe("Speak v2 (Flux) WebSocket TTS streaming", () => {
6985 it ( "should send Speak text and receive binary audio + control messages" , async ( ) => {
7086 const receivedMessages : any [ ] = [ ] ;
7187 const sentToServer : any [ ] = [ ] ;
88+ const tracker = new WebSocketEventTracker ( ) ;
7289
7390 const client = makeClient ( ) ;
7491
@@ -77,62 +94,74 @@ describe("Speak v2 (Flux) WebSocket TTS streaming", () => {
7794 encoding : "linear16" ,
7895 sample_rate : "24000" ,
7996 } ) ;
97+ openSockets . push ( socket ) ;
8098
8199 socket . on ( "message" , ( data ) => {
82100 receivedMessages . push ( data ) ;
101+ const isBinary = data instanceof ArrayBuffer || data instanceof Blob ;
102+ tracker . track ( isBinary ? "binary" : ( ( data as { type ?: string } ) ?. type ?? "unknown" ) ) ;
83103 } ) ;
84104
85105 wsServer . on ( "connection" , ( ws ) => {
86106 // Server greets with Connected on open
87- ws . send (
88- JSON . stringify ( {
89- type : "Connected" ,
90- request_id : "req-123" ,
91- model_name : "flux-alexis-en" ,
92- model_version : "2025-01-01" ,
93- model_uuids : [ "uuid-1" ] ,
94- } ) ,
95- ) ;
107+ const connected : Deepgram . speak . SpeakV2Connected = {
108+ type : "Connected" ,
109+ request_id : "req-123" ,
110+ model_name : "flux-alexis-en" ,
111+ model_version : "2025-01-01" ,
112+ model_uuids : [ "uuid-1" ] ,
113+ } ;
114+ ws . send ( JSON . stringify ( connected ) ) ;
96115
97116 ws . on ( "message" , ( data ) => {
98117 const parsed = JSON . parse ( data . toString ( ) ) ;
99118 sentToServer . push ( parsed ) ;
100119
101120 if ( parsed . type === "Speak" ) {
102- ws . send ( JSON . stringify ( { type : "SpeechStarted" , speech_id : "dg_sp_abcdef012345" } ) ) ;
121+ const speechStarted : Deepgram . speak . SpeakV2SpeechStarted = {
122+ type : "SpeechStarted" ,
123+ speech_id : "dg_sp_abcdef012345" ,
124+ } ;
125+ ws . send ( JSON . stringify ( speechStarted ) ) ;
103126 // Binary audio frames — MUST NOT be parsed as JSON by the client
104127 ws . send ( generateMockAudioData ( 1024 ) ) ;
105128 ws . send ( generateMockAudioData ( 2048 ) ) ;
106129 }
107130
108131 if ( parsed . type === "Flush" ) {
109132 ws . send ( generateMockAudioData ( 512 ) ) ;
110- ws . send (
111- JSON . stringify ( {
112- type : "SpeechMetadata" ,
113- speech_id : "dg_sp_abcdef012345" ,
114- audio_duration_ms : 1234 ,
115- input_character_count : 22 ,
116- billable_character_count : 22 ,
117- controls_applied : {
118- pronunciations_applied : 0 ,
119- pronunciation_warnings : 0 ,
120- } ,
121- } ) ,
122- ) ;
123- ws . send ( JSON . stringify ( { type : "Flushed" , speech_id : "dg_sp_abcdef012345" } ) ) ;
133+ const speechMetadata : Deepgram . speak . SpeakV2SpeechMetadata = {
134+ type : "SpeechMetadata" ,
135+ speech_id : "dg_sp_abcdef012345" ,
136+ audio_duration_ms : 1234 ,
137+ input_character_count : 22 ,
138+ billable_character_count : 22 ,
139+ controls_applied : {
140+ pronunciations_applied : 0 ,
141+ pronunciation_warnings : 0 ,
142+ } ,
143+ } ;
144+ ws . send ( JSON . stringify ( speechMetadata ) ) ;
145+ const flushed : Deepgram . speak . SpeakV2Flushed = {
146+ type : "Flushed" ,
147+ speech_id : "dg_sp_abcdef012345" ,
148+ } ;
149+ ws . send ( JSON . stringify ( flushed ) ) ;
124150 }
125151 } ) ;
126152 } ) ;
127153
128154 socket . connect ( ) ;
129155 await socket . waitForOpen ( ) ;
156+ await waitForEventCount ( tracker , "Connected" , 1 ) ;
130157
131158 socket . sendSpeak ( { type : "Speak" , text : "Hello from Flux." } ) ;
132- await new Promise ( ( resolve ) => setTimeout ( resolve , 300 ) ) ;
159+ await waitForEventCount ( tracker , "SpeechStarted" , 1 ) ;
160+ await waitForEventCount ( tracker , "binary" , 2 ) ;
133161
134162 socket . sendFlush ( { type : "Flush" } ) ;
135- await new Promise ( ( resolve ) => setTimeout ( resolve , 300 ) ) ;
163+ await waitForEventCount ( tracker , "Flushed" , 1 ) ;
164+ await waitForEventCount ( tracker , "binary" , 3 ) ;
136165
137166 // Verify messages sent to the server
138167 expect ( sentToServer ) . toEqual ( [ { type : "Speak" , text : "Hello from Flux." } , { type : "Flush" } ] ) ;
@@ -141,10 +170,11 @@ describe("Speak v2 (Flux) WebSocket TTS streaming", () => {
141170 const connected = receivedMessages . find ( ( m ) => m ?. type === "Connected" ) ;
142171 expect ( connected ) . toMatchObject ( { type : "Connected" , request_id : "req-123" } ) ;
143172
144- // Binary audio frames arrived as binary (NOT parsed/dropped as JSON)
173+ // Exactly 3 binary audio frames arrived as binary (NOT parsed/dropped as JSON,
174+ // and NOT duplicated by a listener bug).
145175 const isBinary = ( d : any ) => d instanceof ArrayBuffer || d instanceof Blob ;
146176 const binaryFrames = receivedMessages . filter ( isBinary ) ;
147- expect ( binaryFrames . length ) . toBeGreaterThanOrEqual ( 3 ) ;
177+ expect ( binaryFrames ) . toHaveLength ( 3 ) ;
148178
149179 // Control messages parsed correctly
150180 expect ( receivedMessages . find ( ( m ) => m ?. type === "SpeechStarted" ) ) . toMatchObject ( {
@@ -165,13 +195,14 @@ describe("Speak v2 (Flux) WebSocket TTS streaming", () => {
165195 } ) ;
166196
167197 describe ( "Close command" , ( ) => {
168- it ( "should send Close and receive the close event " , async ( ) => {
198+ it ( "should send Close and receive a 1000 close with the server's reason " , async ( ) => {
169199 const sentToServer : any [ ] = [ ] ;
170200 const tracker = new WebSocketEventTracker ( ) ;
171201
172202 const client = makeClient ( ) ;
173203
174204 const socket = await client . speak . v2 . createConnection ( { model : "flux-alexis-en" } ) ;
205+ openSockets . push ( socket ) ;
175206
176207 socket . on ( "close" , ( event ) => tracker . track ( "close" , event ) ) ;
177208
@@ -189,10 +220,13 @@ describe("Speak v2 (Flux) WebSocket TTS streaming", () => {
189220 await socket . waitForOpen ( ) ;
190221
191222 socket . sendClose ( { type : "Close" } ) ;
192- await new Promise ( ( resolve ) => setTimeout ( resolve , 300 ) ) ;
223+ await waitForEventCount ( tracker , "close" , 1 , 5000 ) ;
193224
225+ // The server only closes in response to receiving Close, so a close event
226+ // with code 1000 + the server's reason proves the round-trip completed.
194227 expect ( sentToServer ) . toEqual ( [ { type : "Close" } ] ) ;
195- expect ( tracker . getCount ( "close" ) ) . toBeGreaterThanOrEqual ( 1 ) ;
228+ const closeEvent = tracker . getHistory ( ) . find ( ( e ) => e . event === "close" ) ;
229+ expect ( closeEvent ?. data ) . toMatchObject ( { code : 1000 , reason : "Closing as requested" } ) ;
196230
197231 socket . close ( ) ;
198232 } ) ;
@@ -201,39 +235,44 @@ describe("Speak v2 (Flux) WebSocket TTS streaming", () => {
201235 describe ( "Warning and Error handling" , ( ) => {
202236 it ( "should deliver Warning and Error control messages" , async ( ) => {
203237 const receivedMessages : any [ ] = [ ] ;
238+ const tracker = new WebSocketEventTracker ( ) ;
204239
205240 const client = makeClient ( ) ;
206241
207242 const socket = await client . speak . v2 . createConnection ( { model : "flux-alexis-en" } ) ;
243+ openSockets . push ( socket ) ;
208244
209- socket . on ( "message" , ( data ) => receivedMessages . push ( data ) ) ;
245+ socket . on ( "message" , ( data ) => {
246+ receivedMessages . push ( data ) ;
247+ tracker . track ( ( data as { type ?: string } ) ?. type ?? "binary" ) ;
248+ } ) ;
210249
211250 wsServer . on ( "connection" , ( ws ) => {
212- ws . send (
213- JSON . stringify ( {
214- type : "Warning" ,
215- code : "NO_ACTIVE_SPEECH" ,
216- description : "A speech-scoped message arrived with no active turn." ,
217- } ) ,
218- ) ;
251+ const warning : Deepgram . speak . SpeakV2Warning = {
252+ type : "Warning" ,
253+ code : "NO_ACTIVE_SPEECH" ,
254+ description : "A speech-scoped message arrived with no active turn." ,
255+ } ;
256+ ws . send ( JSON . stringify ( warning ) ) ;
219257 ws . on ( "message" , ( data ) => {
220258 const parsed = JSON . parse ( data . toString ( ) ) ;
221259 if ( parsed . type === "Speak" ) {
222- ws . send (
223- JSON . stringify ( {
224- type : "Error" ,
225- code : "NET-0000" ,
226- description : "Synthesis failed." ,
227- } ) ,
228- ) ;
260+ const error : Deepgram . speak . SpeakV2Error = {
261+ type : "Error" ,
262+ code : "NET-0000" ,
263+ description : "Synthesis failed." ,
264+ } ;
265+ ws . send ( JSON . stringify ( error ) ) ;
229266 }
230267 } ) ;
231268 } ) ;
232269
233270 socket . connect ( ) ;
234271 await socket . waitForOpen ( ) ;
272+ await waitForEventCount ( tracker , "Warning" , 1 ) ;
273+
235274 socket . sendSpeak ( { type : "Speak" , text : "Test" } ) ;
236- await new Promise ( ( resolve ) => setTimeout ( resolve , 300 ) ) ;
275+ await waitForEventCount ( tracker , "Error" , 1 ) ;
237276
238277 expect ( receivedMessages . find ( ( m ) => m ?. type === "Warning" ) ) . toMatchObject ( {
239278 type : "Warning" ,
@@ -249,15 +288,16 @@ describe("Speak v2 (Flux) WebSocket TTS streaming", () => {
249288 } ) ;
250289
251290 describe ( "Guard rails" , ( ) => {
252- it ( "should throw when sending before the connection is open" , async ( ) => {
291+ it ( "should throw 'Socket is not open' when sending before the connection is open" , async ( ) => {
253292 const client = makeClient ( ) ;
254293
255294 const socket = await client . speak . v2 . createConnection ( { model : "flux-alexis-en" } ) ;
295+ openSockets . push ( socket ) ;
256296
257- // Not connected — sending must throw
297+ // Not connected — sending must throw with the specific not-open error
258298 expect ( ( ) => {
259299 socket . sendSpeak ( { type : "Speak" , text : "Test" } ) ;
260- } ) . toThrow ( ) ;
300+ } ) . toThrow ( "Socket is not open" ) ;
261301
262302 socket . close ( ) ;
263303 } ) ;
0 commit comments