Skip to content

Commit ada42f2

Browse files
committed
test: harden speak-v2 + agent-listen-update wire tests
Address review findings on the new wire tests (test-only, no runtime change): - Replace fixed setTimeout sleeps with waitForEventCount (avoids CI flakiness). - Type server-sent payloads with the real SpeakV2*/AgentV1* types so a schema drift surfaces as a compile error instead of silently passing. - Assert the close round-trip via code 1000 + the server's reason (was a bare count >= 1); assert the guard-rail throws the specific 'Socket is not open'. - Tighten the binary-frame count to exactly 3 (=== not >=) to catch a duplicate-listener regression. - Move socket cleanup into afterEach so a thrown assertion can't leak a reconnecting socket into later tests.
1 parent 6bb0d16 commit ada42f2

2 files changed

Lines changed: 118 additions & 61 deletions

File tree

tests/wire/websocket/agent-listen-update.test.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { describe, it, expect, beforeEach, afterEach } from "vitest";
22
import { DeepgramClient } from "../../../src";
3+
import type { Deepgram } from "../../../src";
34
import { mockServerPool } from "../../mock-server/MockServerPool";
45
import { MockServer } from "../../mock-server/MockServer";
6+
import { WebSocketEventTracker, waitForEventCount } from "./helpers";
57
import type { Server } from "ws";
68

79
/**
@@ -13,12 +15,15 @@ import type { Server } from "ws";
1315
* - AgentV1ListenUpdated (server -> client, "ListenUpdated" confirmation)
1416
*
1517
* This verifies the message serializes correctly over the wire and the
16-
* confirmation is delivered back through on("message").
18+
* confirmation is delivered back through on("message"). The UpdateListen payload
19+
* is typed so a schema drift surfaces as a compile error; the wait uses
20+
* waitForEventCount rather than a fixed sleep.
1721
*/
1822
describe("Agent UpdateListen / ListenUpdated", () => {
1923
let server: MockServer;
2024
let wsServer: Server;
2125
let wsPort: number;
26+
const openSockets: Array<{ close: () => void }> = [];
2227

2328
beforeEach(async () => {
2429
server = mockServerPool.createServer();
@@ -47,12 +52,21 @@ describe("Agent UpdateListen / ListenUpdated", () => {
4752
});
4853

4954
afterEach(() => {
55+
// Close any sockets a test created, even if it threw before its own cleanup.
56+
for (const socket of openSockets) {
57+
try {
58+
socket.close();
59+
} catch {
60+
// already closed
61+
}
62+
}
63+
openSockets.length = 0;
5064
wsServer?.close();
5165
});
5266

5367
it("should send UpdateListen (with a v2 provider) and receive ListenUpdated", async () => {
5468
const sentToServer: any[] = [];
55-
const receivedMessages: any[] = [];
69+
const tracker = new WebSocketEventTracker();
5670

5771
const client = new DeepgramClient({
5872
maxRetries: 0,
@@ -65,24 +79,27 @@ describe("Agent UpdateListen / ListenUpdated", () => {
6579
});
6680

6781
const socket = await client.agent.v1.createConnection();
82+
openSockets.push(socket);
6883

69-
socket.on("message", (data) => receivedMessages.push(data));
84+
socket.on("message", (data) => tracker.track((data as { type?: string })?.type ?? "binary", data));
7085

7186
wsServer.on("connection", (ws) => {
7287
ws.send(JSON.stringify({ type: "Welcome" }));
7388
ws.on("message", (data) => {
7489
const parsed = JSON.parse(data.toString());
7590
sentToServer.push(parsed);
7691
if (parsed.type === "UpdateListen") {
77-
ws.send(JSON.stringify({ type: "ListenUpdated" }));
92+
const updated: Deepgram.agent.AgentV1ListenUpdated = { type: "ListenUpdated" };
93+
ws.send(JSON.stringify(updated));
7894
}
7995
});
8096
});
8197

8298
socket.connect();
8399
await socket.waitForOpen();
100+
await waitForEventCount(tracker, "Welcome", 1);
84101

85-
socket.sendUpdateListen({
102+
const updateListen: Deepgram.agent.AgentV1UpdateListen = {
86103
type: "UpdateListen",
87104
listen: {
88105
provider: {
@@ -95,9 +112,10 @@ describe("Agent UpdateListen / ListenUpdated", () => {
95112
eot_timeout_ms: 4000,
96113
},
97114
},
98-
});
115+
};
116+
socket.sendUpdateListen(updateListen);
99117

100-
await new Promise((resolve) => setTimeout(resolve, 200));
118+
await waitForEventCount(tracker, "ListenUpdated", 1);
101119

102120
// The UpdateListen message serialized with its full provider payload
103121
const update = sentToServer.find((m) => m.type === "UpdateListen");
@@ -115,9 +133,8 @@ describe("Agent UpdateListen / ListenUpdated", () => {
115133
});
116134

117135
// The server confirmation is delivered back
118-
expect(receivedMessages.find((m) => m?.type === "ListenUpdated")).toMatchObject({
119-
type: "ListenUpdated",
120-
});
136+
const confirmation = tracker.getHistory().find((e) => e.event === "ListenUpdated");
137+
expect(confirmation?.data).toMatchObject({ type: "ListenUpdated" });
121138

122139
socket.close();
123140
});

tests/wire/websocket/speak-v2-tts.test.ts

Lines changed: 91 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { describe, it, expect, beforeEach, afterEach } from "vitest";
22
import { DeepgramClient } from "../../../src";
3+
import type { Deepgram } from "../../../src";
34
import { mockServerPool } from "../../mock-server/MockServerPool";
45
import { MockServer } from "../../mock-server/MockServer";
5-
import { generateMockAudioData, WebSocketEventTracker } from "./helpers";
6+
import { generateMockAudioData, WebSocketEventTracker, waitForEventCount } from "./helpers";
67
import 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
*/
2126
describe("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

Comments
 (0)