Files
hermes-webui/tests/test_extension_tts_engine_registration.py
T
nesquena-hermes cd71403be9 feat(extensions): TTS-engine registration capability (registerHermesTtsEngine)
Lets a trusted local extension contribute a TTS engine that appears in the
Settings -> TTS Engine dropdown and is used by BOTH playback paths (voice-mode
auto-read in boot.js _speakResponse + the per-message Listen button in ui.js
speakMessage). Mirrors window.registerHermesSkin.

  window.registerHermesTtsEngine({ id, label, synthesize(text, opts) })
    -> Promise<ArrayBuffer|Blob> of audio; core coerces + plays through the same
       <audio> lifecycle as the Edge engine (stop/rearm in voice mode).

Core owns selection, the dropdown <option> (label via textContent, never HTML),
and playback. Built-in ids (browser/edge/elevenlabs) are reserved; invalid
descriptors are rejected; empty/failed synth degrades gracefully. The settings
panel re-adds registered options on render (the markup hardcodes only built-ins).

Unblocks TTS-engine extensions (e.g. a local VOICEVOX engine). Docs in
docs/EXTENSIONS.md; CHANGELOG [Unreleased]. New regression test drives the real
registry via a Node harness (register/validation/reserved-guard/synth coercion).

Full suite: 10893 passed, 0 failed. Verified end-to-end in a live browser with a
companion VOICEVOX extension: engine registers, appears in the dropdown
(browser/edge/elevenlabs/voicevox), and the Listen button routes through the
registered synthesize (both VOICEVOX steps called).
2026-06-28 05:43:02 +00:00

109 lines
4.4 KiB
Python

"""Extension TTS-engine registration capability (window.registerHermesTtsEngine).
Two layers:
1. Structural — the public API + the two playback paths + the settings re-add
hook exist.
2. Behavioral — a Node harness extracts the registry from boot.js and exercises
register / validation / reserved-key guard / synth type-coercion.
"""
from __future__ import annotations
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parent.parent
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
def test_public_api_present():
assert "window.registerHermesTtsEngine=function" in BOOT_JS
assert "window._hermesTtsSynth=function" in BOOT_JS
assert "window._hermesTtsIsRegistered=function" in BOOT_JS
assert "window._hermesTtsEngineOptions=function" in BOOT_JS
def test_reserved_builtins_guarded():
assert "_HERMES_TTS_RESERVED" in BOOT_JS
# browser/edge/elevenlabs must be reserved so an extension can't shadow them
assert "browser:1" in BOOT_JS and "edge:1" in BOOT_JS and "elevenlabs:1" in BOOT_JS
def test_both_playback_paths_check_registry():
# voice-mode auto-read (boot.js _speakResponse) and the per-message Listen
# button (ui.js speakMessage) must both route registered engines.
assert "_hermesTtsIsRegistered(engine)" in BOOT_JS, "voice-mode path must check the registry"
assert "_hermesTtsIsRegistered(engine)" in UI_JS, "per-message path must check the registry"
def test_settings_panel_readds_registered_options():
assert "_hermesTtsEngineOptions" in PANELS_JS, (
"settings panel must re-add registered engine options on render"
)
def test_option_label_uses_textcontent_not_innerhtml():
# the engine label goes into the dropdown via textContent (no HTML injection)
assert "opt.textContent=label" in BOOT_JS
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_registration_behavior():
"""Drive the real registry logic from boot.js in a Node harness."""
start = BOOT_JS.index("var _HERMES_TTS_ENGINES")
end = BOOT_JS.index("window._hermesTtsSynth=function")
end = BOOT_JS.index("};", BOOT_JS.index("throw new Error('TTS engine returned", end)) + 2
region = BOOT_JS[start:end]
harness = textwrap.dedent(
"""
const window = {};
const document = { getElementById: () => null }; // no <select> in harness
%s
const results = {};
// valid registration
results.validOk = window.registerHermesTtsEngine({
id: 'voicevox', label: 'VOICEVOX', synthesize: () => new ArrayBuffer(4)
});
results.isRegistered = window._hermesTtsIsRegistered('voicevox');
// reserved key rejected
results.reservedRejected = (window.registerHermesTtsEngine({
id: 'edge', label: 'x', synthesize: () => new ArrayBuffer(1) }) === false);
// bad id rejected
results.badIdRejected = (window.registerHermesTtsEngine({
id: 'Bad Id!', label: 'x', synthesize: () => new ArrayBuffer(1) }) === false);
// missing synthesize rejected
results.noSynthRejected = (window.registerHermesTtsEngine({ id: 'nosynth', label: 'x' }) === false);
// options list reflects the registered engine
results.optionListed = window._hermesTtsEngineOptions().some(e => e.id === 'voicevox');
// synth coerces ArrayBuffer through
window._hermesTtsSynth('voicevox', 'hi', {}).then(buf => {
results.synthReturnsArrayBuffer = (buf instanceof ArrayBuffer);
// unregistered engine returns null
results.unregisteredNull = (window._hermesTtsSynth('ghost', 'hi', {}) === null);
console.log(JSON.stringify(results));
});
"""
) % region
out = subprocess.run(
["node", "-e", harness], capture_output=True, text=True, timeout=30
)
assert out.returncode == 0, f"harness failed: {out.stderr}"
import json
r = json.loads(out.stdout.strip().splitlines()[-1])
assert r["validOk"] is True
assert r["isRegistered"] is True
assert r["reservedRejected"] is True
assert r["badIdRejected"] is True
assert r["noSynthRejected"] is True
assert r["optionListed"] is True
assert r["synthReturnsArrayBuffer"] is True
assert r["unregisteredNull"] is True