Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 60 additions & 7 deletions src/primitives/announcer/speech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand Down Expand Up @@ -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<void>((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();
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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],
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
}
Expand Down
218 changes: 218 additions & 0 deletions tests/announcer-speech-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).SpeechSynthesisUtterance =
FakeUtterance;
(window as unknown as Record<string, unknown>).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<string, unknown>)
.SpeechSynthesisUtterance;
delete (window as unknown as Record<string, unknown>).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<string, unknown>)
.SpeechSynthesisUtterance,
).toBeUndefined();
expect(
(window as unknown as Record<string, unknown>).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');
});
});
Loading