diff --git a/extensions/microsoft/speech-provider.test.ts b/extensions/microsoft/speech-provider.test.ts index 729c4840eacf..1f340c5040bf 100644 --- a/extensions/microsoft/speech-provider.test.ts +++ b/extensions/microsoft/speech-provider.test.ts @@ -105,6 +105,52 @@ describe("listMicrosoftVoices", () => { ); }); + it("returns an empty catalog for a malformed top-level payload", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue( + new Response("null", { status: 200 }), + ) as unknown as typeof globalThis.fetch; + + await expect(listVoicesThroughProvider()).resolves.toEqual([]); + }); + + it("skips malformed rows without discarding valid voices", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify([ + null, + "unexpected", + [], + { ShortName: 42 }, + { + ShortName: "en-US-AvaNeural", + FriendlyName: "Microsoft Ava Online (Natural) - English (United States)", + Locale: "en-US", + Gender: "Female", + VoiceTag: { + ContentCategories: [null, "General"], + VoicePersonalities: [false, "Friendly", "Positive"], + }, + }, + ]), + { status: 200 }, + ), + ) as unknown as typeof globalThis.fetch; + + await expect(listVoicesThroughProvider()).resolves.toEqual([ + { + id: "en-US-AvaNeural", + name: "Microsoft Ava Online (Natural) - English (United States)", + category: "General", + description: "Friendly, Positive", + locale: "en-US", + gender: "Female", + personalities: ["Friendly", "Positive"], + }, + ]); + }); + it("throws on Microsoft voice list failures", async () => { globalThis.fetch = vi .fn() diff --git a/extensions/microsoft/speech-provider.ts b/extensions/microsoft/speech-provider.ts index 8f4f45f4a078..508b5d9bbe07 100644 --- a/extensions/microsoft/speech-provider.ts +++ b/extensions/microsoft/speech-provider.ts @@ -47,17 +47,6 @@ type MicrosoftProviderConfig = { timeoutMs?: number; }; -type MicrosoftVoiceListEntry = { - ShortName?: string; - FriendlyName?: string; - Locale?: string; - Gender?: string; - VoiceTag?: { - ContentCategories?: string[]; - VoicePersonalities?: string[]; - }; -}; - function normalizeMicrosoftProviderConfig( rawConfig: Record, ): MicrosoftProviderConfig { @@ -114,9 +103,10 @@ function buildMicrosoftVoiceHeaders(): Record { }; } -function formatMicrosoftVoiceDescription(entry: MicrosoftVoiceListEntry): string | undefined { - const personalities = entry.VoiceTag?.VoicePersonalities?.filter(Boolean) ?? []; - return personalities.length > 0 ? personalities.join(", ") : undefined; +function readMicrosoftVoiceTagStrings(value: unknown): string[] | undefined { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : undefined; } function isCjkDominant(text: string): boolean { @@ -173,24 +163,29 @@ async function listMicrosoftVoices( }); } await assertOkOrThrowProviderError(response, "Microsoft voices API error"); - const voices = await readProviderJsonResponse( - response, - "microsoft.speech-voices", - ); + const voices = await readProviderJsonResponse(response, "microsoft.speech-voices"); return Array.isArray(voices) - ? voices - .map((voice) => ({ - id: voice.ShortName?.trim() ?? "", - name: trimToUndefined(voice.FriendlyName) ?? trimToUndefined(voice.ShortName), - category: voice.VoiceTag?.ContentCategories?.find((value) => value.trim().length > 0), - description: formatMicrosoftVoiceDescription(voice), - locale: trimToUndefined(voice.Locale), - gender: trimToUndefined(voice.Gender), - personalities: voice.VoiceTag?.VoicePersonalities?.filter( - (value): value is string => value.trim().length > 0, - ), - })) - .filter((voice) => voice.id.length > 0) + ? voices.flatMap((value) => { + const voice = asObject(value); + const id = trimToUndefined(voice?.ShortName); + if (!voice || !id) { + return []; + } + const voiceTag = asObject(voice.VoiceTag); + const categories = readMicrosoftVoiceTagStrings(voiceTag?.ContentCategories); + const personalities = readMicrosoftVoiceTagStrings(voiceTag?.VoicePersonalities); + return [ + { + id, + name: trimToUndefined(voice.FriendlyName) ?? id, + category: categories?.[0], + description: personalities?.length ? personalities.join(", ") : undefined, + locale: trimToUndefined(voice.Locale), + gender: trimToUndefined(voice.Gender), + personalities, + }, + ]; + }) : []; } finally { await release();