diff --git a/src/primitives/announcer/speech.ts b/src/primitives/announcer/speech.ts index b4ff14d9..e9987db1 100644 --- a/src/primitives/announcer/speech.ts +++ b/src/primitives/announcer/speech.ts @@ -26,6 +26,24 @@ interface SpeechError extends Error { error?: string; } +// The same caveat applies to the rest of the Web Speech API, and it is not just +// a typing nicety: webOS (Chrome 53) ships no Web Speech API at all, so it +// declares neither `window.speechSynthesis` nor the `SpeechSynthesisUtterance` +// constructor. Merely *evaluating* the bare global throws a ReferenceError — +// including inside an `instanceof` on a code path that never intends to speak, +// which is how a webOS app running in aria mode (Announcer.aria = true, speech +// synthesis never touched) still managed to crash. `typeof` is the only safe +// probe for a possibly-undeclared global, so every use of the API in this file +// goes through this guard or through a `SpeechSynthesis | undefined` local. +function isSpeechSynthesisUtterance( + phrase: unknown, +): phrase is SpeechSynthesisUtterance { + return ( + typeof SpeechSynthesisUtterance !== 'undefined' && + phrase instanceof SpeechSynthesisUtterance + ); +} + function flattenStrings(series: SpeechType[] = []): SpeechType[] { const flattenedSeries = []; @@ -142,9 +160,20 @@ function speak( lang = 'en-US', voiceName?: string, ) { - const synth = window.speechSynthesis; + // TypeScript's DOM lib types `window.speechSynthesis` as always present, but + // on a TV browser without the Web Speech API it is `undefined` at runtime. + // Type it honestly so the compiler forces the guard below. + const synth: SpeechSynthesis | undefined = window.speechSynthesis; return new Promise((resolve, reject) => { + if (!synth || typeof SpeechSynthesisUtterance === 'undefined') { + // No speech engine on this device. Resolve rather than reject so the rest + // of the series still runs and the app degrades to silence instead of + // throwing a ReferenceError out of the chain. + resolve(); + return; + } + let selectedVoice; if (voiceName) { const availableVoices = synth.getVoices(); @@ -180,7 +209,8 @@ function speak( * benign — a newer announcement cancelled or replaced the in-flight one (see * synth.cancel()), which happens constantly during directional navigation — so * we stop retrying without surfacing them. `network` errors back off and retry. - * Anything else is genuinely unexpected and is rethrown. + * Anything else is a device-level failure of the speech engine: logged, not + * rethrown. */ async function handleSpeechError( e: unknown, @@ -202,7 +232,16 @@ async function handleSpeechError( return 0; // benign — stop retrying, don't propagate } - throw e; + // Everything else — "synthesis-failed", "not-allowed", or an error carrying no + // code at all — is the platform's TTS engine declining to speak, not a + // programming error. Xumo (Safari 11) and Tizen both report these routinely + // when the system voice is unavailable or refuses to start unprompted. + // Rethrowing used to abort the whole remaining series *and* surface as an + // uncaught exception in the host app, so instead we log it, keep the rest of + // the phrases going, and stop retrying (a refused engine won't change its + // mind on the next attempt the way a `network` blip might). + console.warn(`Speech synthesis failed: ${code || 'no error code'}`); + return 0; } function speakSeries( @@ -212,7 +251,8 @@ function speakSeries( voice?: string, root = true, ): SeriesResult { - const synth = window.speechSynthesis; + // Possibly-undefined on purpose — see isSpeechSynthesisUtterance above. + const synth: SpeechSynthesis | undefined = window.speechSynthesis; const remainingPhrases = flattenStrings( Array.isArray(series) ? series : [series], ); @@ -255,7 +295,7 @@ function speakSeries( ); } } - } else if (phrase instanceof SpeechSynthesisUtterance) { + } else if (isSpeechSynthesisUtterance(phrase)) { // Handle SpeechSynthesisUtterance objects with retry logic const totalRetries = 3; let retriesLeft = totalRetries; @@ -298,7 +338,16 @@ function speakSeries( focusElementForAria(); } } - })(); + })().catch((e) => { + // Last line of defence, and the reason the fixes above are not enough on + // their own: `SeriesResult.series` is handed to callers who usually never + // await it, so anything escaping this chain lands as an `unhandledrejection` + // and is reported as an uncaught exception in the host app. A failed + // announcement must never do that — whatever the cause (a caller-supplied + // function phrase throwing, a future synthesis error code), log it and let + // the promise resolve. + console.warn('Speech series failed:', e); + }); return { series: seriesChain, @@ -320,7 +369,11 @@ function speakSeries( // screen reader can finish. Just drop any partially accumulated // phrases from this canceled series. ariaLabelPhrases = []; - } else { + } else if (synth) { + // Undefined on TV browsers without the Web Speech API. cancel() runs + // synchronously in the caller (see the default export, which cancels + // the previous series before starting a new one), so an unguarded + // call here would throw straight into app code. synth.cancel(); // Cancel all ongoing speech } } diff --git a/tests/announcer-speech-errors.spec.ts b/tests/announcer-speech-errors.spec.ts new file mode 100644 index 00000000..ac05fc05 --- /dev/null +++ b/tests/announcer-speech-errors.spec.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import speak from '../src/primitives/announcer/speech.ts'; + +// These tests cover the two production crash classes reported from real TV +// hardware: +// +// A. webOS (Chrome 53) ships no Web Speech API at all, so touching the bare +// `SpeechSynthesisUtterance` global throws a ReferenceError — even on the +// aria code path that never intends to speak. jsdom happens to model this +// platform exactly: it declares neither global, so the "absent API" tests +// below need no setup. +// B. Xumo (Safari 11) and Tizen routinely report device-level synthesis +// failures ("synthesis-failed", "not-allowed", or no code at all). These +// must not escape as unhandled rejections, because callers hold +// `SeriesResult.series` without awaiting it. + +const ARIA_PARENT_ID = 'aria-parent'; + +function ariaLabels(): string[] { + const parent = document.getElementById(ARIA_PARENT_ID); + if (!parent) return []; + return Array.from(parent.querySelectorAll('span')).map( + (span) => span.getAttribute('aria-label') ?? '', + ); +} + +type FakeErrorEvent = { error?: string }; + +class FakeUtterance { + text: string; + lang = ''; + voice: unknown = null; + onend: (() => void) | null = null; + onerror: ((e: FakeErrorEvent) => void) | null = null; + + constructor(text: string) { + this.text = text; + } +} + +/** + * Stand in for a platform that *does* expose the Web Speech API. `onSpeak` + * decides how the fake engine responds; it receives the utterance and the + * 1-based attempt number so retry behaviour can be asserted. + */ +function installSpeechEngine( + onSpeak: (utterance: FakeUtterance, attempt: number) => void, +): FakeUtterance[] { + const spoken: FakeUtterance[] = []; + + const synth = { + speak(utterance: FakeUtterance) { + spoken.push(utterance); + onSpeak(utterance, spoken.length); + }, + cancel() {}, + getVoices() { + return []; + }, + }; + + (globalThis as unknown as Record).SpeechSynthesisUtterance = + FakeUtterance; + (window as unknown as Record).speechSynthesis = synth; + + return spoken; +} + +function failWith(code: string | undefined) { + return (utterance: FakeUtterance) => { + if (utterance.onerror) utterance.onerror({ error: code }); + }; +} + +afterEach(() => { + // vitest runs with `isolate: false`, so a leaked global would follow us into + // every other spec file. Put the environment back to a bare webOS-like one. + delete (globalThis as unknown as Record) + .SpeechSynthesisUtterance; + delete (window as unknown as Record).speechSynthesis; + document.getElementById(ARIA_PARENT_ID)?.remove(); + vi.restoreAllMocks(); +}); + +describe('Announcer on a platform without the Web Speech API', () => { + it('has no Web Speech globals to begin with (the webOS case)', () => { + expect( + (globalThis as unknown as Record) + .SpeechSynthesisUtterance, + ).toBeUndefined(); + expect( + (window as unknown as Record).speechSynthesis, + ).toBeUndefined(); + }); + + it('runs an aria series containing a non-string phrase to completion', async () => { + // A function or nested-array phrase reaches the `instanceof + // SpeechSynthesisUtterance` branch of the dispatch chain. Evaluating that + // bare global threw a ReferenceError on webOS, taking down the whole + // announcement even though aria mode never speaks. + const result = speak([() => ['Nested label'], 'Tail label'], true); + + await expect(result.series).resolves.toBeUndefined(); + expect(ariaLabels()).toEqual(['Nested label', 'Tail label']); + }); + + it('runs a non-aria series to completion, degrading to silence', async () => { + const result = speak(['No engine here'], false); + + await expect(result.series).resolves.toBeUndefined(); + }); + + it('cancels the previous series without throwing into app code', async () => { + const first = speak(['One'], false); + + // The default export cancels the in-flight series synchronously, which hits + // `synth.cancel()` on a `window.speechSynthesis` that does not exist. + expect(() => speak(['Two'], false)).not.toThrow(); + + await expect(first.series).resolves.toBeUndefined(); + }); +}); + +describe('Announcer speech error classification', () => { + it.each(['synthesis-failed', 'not-allowed', undefined])( + 'treats a %s error as benign instead of rejecting', + async (code) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const spoken = installSpeechEngine(failWith(code)); + + const result = speak(['Hello there'], false); + + await expect(result.series).resolves.toBeUndefined(); + // A refused engine will not change its mind, so no retries. + expect(spoken.length).toBe(1); + expect(warn).toHaveBeenCalledTimes(1); + }, + ); + + it.each(['canceled', 'interrupted'])( + 'treats a %s error as benign and silent', + async (code) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const spoken = installSpeechEngine(failWith(code)); + + const result = speak(['Interrupted phrase'], false); + + await expect(result.series).resolves.toBeUndefined(); + expect(spoken.length).toBe(1); + expect(warn).not.toHaveBeenCalled(); + }, + ); + + it('retries a network error with backoff, then stops', async () => { + vi.useFakeTimers(); + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const spoken = installSpeechEngine(failWith('network')); + + let settled = 'pending'; + const result = speak(['Flaky phrase'], false); + void result.series.then( + () => { + settled = 'resolved'; + }, + () => { + settled = 'rejected'; + }, + ); + + // Backoff is 500ms, 1000ms, 1500ms across the three attempts. + await vi.advanceTimersByTimeAsync(4000); + + expect(spoken.length).toBe(3); + expect(settled).toBe('resolved'); + expect(warn).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps speaking the rest of the series after a failed phrase', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const spoken = installSpeechEngine((utterance, attempt) => { + if (attempt === 1) { + failWith('synthesis-failed')(utterance); + } else if (utterance.onend) { + utterance.onend(); + } + }); + + const result = speak([() => 'First', () => 'Second'], false); + + await expect(result.series).resolves.toBeUndefined(); + expect(spoken.map((utterance) => utterance.text)).toEqual([ + 'First', + 'Second', + ]); + }); + + it('still speaks SpeechSynthesisUtterance phrases where the API exists', async () => { + const spoken = installSpeechEngine((utterance) => { + if (utterance.onend) utterance.onend(); + }); + + const utterance = new FakeUtterance('From utterance'); + utterance.lang = 'en-GB'; + + const result = speak( + [utterance as unknown as SpeechSynthesisUtterance], + false, + ); + + await expect(result.series).resolves.toBeUndefined(); + expect(spoken.map((u) => u.text)).toEqual(['From utterance']); + expect(spoken[0]!.lang).toBe('en-GB'); + }); +});