@@ -20,6 +20,8 @@ const {
|
20 | 20 | let buildGoogleSpeechProvider: typeof import("./speech-provider.js").buildGoogleSpeechProvider; |
21 | 21 | let testing: typeof import("./speech-provider.js").testing; |
22 | 22 | |
| 23 | +const GOOGLE_TTS_JSON_CAP_BYTES = 16 * 1024 * 1024; |
| 24 | + |
23 | 25 | beforeAll(async () => { |
24 | 26 | ({ buildGoogleSpeechProvider, testing } = await import("./speech-provider.js")); |
25 | 27 | }); |
@@ -56,6 +58,26 @@ function installGoogleTtsRequestMock(pcm = Buffer.from([1, 0, 2, 0])) {
|
56 | 58 | return postJsonRequestMock; |
57 | 59 | } |
58 | 60 | |
| 61 | +function oversizedGoogleTtsJsonResponse(onCancel: () => void): Response { |
| 62 | +const response = new Response( |
| 63 | +new ReadableStream<Uint8Array>({ |
| 64 | +start(controller) { |
| 65 | +controller.enqueue(new Uint8Array(GOOGLE_TTS_JSON_CAP_BYTES + 1)); |
| 66 | +}, |
| 67 | +cancel() { |
| 68 | +onCancel(); |
| 69 | +}, |
| 70 | +}), |
| 71 | +{ headers: { "content-type": "application/json" }, status: 200 }, |
| 72 | +); |
| 73 | +Object.defineProperty(response, "json", { |
| 74 | +value: async () => { |
| 75 | +throw new Error("unbounded json reader was used"); |
| 76 | +}, |
| 77 | +}); |
| 78 | +return response; |
| 79 | +} |
| 80 | + |
59 | 81 | function expectRecordFields(value: unknown, expected: Record<string, unknown>) { |
60 | 82 | if (!value || typeof value !== "object") { |
61 | 83 | throw new Error("Expected record"); |
@@ -149,6 +171,39 @@ describe("Google speech provider", () => {
|
149 | 171 | expect(transcodeAudioBufferToOpusMock).not.toHaveBeenCalled(); |
150 | 172 | }); |
151 | 173 | |
| 174 | +it("bounds oversized Gemini TTS success JSON responses and cancels the stream", async () => { |
| 175 | +let cancelCount = 0; |
| 176 | +const release = vi.fn(async () => {}); |
| 177 | +postJsonRequestMock |
| 178 | +.mockResolvedValueOnce({ |
| 179 | +response: oversizedGoogleTtsJsonResponse(() => { |
| 180 | +cancelCount += 1; |
| 181 | +}), |
| 182 | + release, |
| 183 | +}) |
| 184 | +.mockResolvedValueOnce({ |
| 185 | +response: oversizedGoogleTtsJsonResponse(() => { |
| 186 | +cancelCount += 1; |
| 187 | +}), |
| 188 | + release, |
| 189 | +}); |
| 190 | +const provider = buildGoogleSpeechProvider(); |
| 191 | + |
| 192 | +await expect( |
| 193 | +provider.synthesize({ |
| 194 | +text: "oversized tts response", |
| 195 | +cfg: {}, |
| 196 | +providerConfig: { |
| 197 | +apiKey: "google-test-key", |
| 198 | +}, |
| 199 | +target: "audio-file", |
| 200 | +timeoutMs: 12_000, |
| 201 | +}), |
| 202 | +).rejects.toThrow("Google TTS response: JSON response exceeds 16777216 bytes"); |
| 203 | +expect(cancelCount).toBe(2); |
| 204 | +expect(release).toHaveBeenCalledTimes(2); |
| 205 | +}); |
| 206 | + |
152 | 207 | it("transcodes Gemini PCM to Opus for voice-note targets", async () => { |
153 | 208 | installGoogleTtsRequestMock(Buffer.from([5, 0, 6, 0])); |
154 | 209 | transcodeAudioBufferToOpusMock.mockResolvedValueOnce(Buffer.from("google-opus")); |
|