tests/test_exec_voice_browser.py
Rama master · réplica de lectura del repositorio original.
"""Exec-voice behaviour tests (WebKit / playwright).
Proves the GLaDOS voice actually FIRES for Exec's turns, and only the right
ones: an assistant reply is spoken (in the glados/piper voice, markdown +
bracketed bits stripped), a voice turned OFF stays silent, and user text is
never voiced. Mirrors the tarot browser suite — mock the boundaries, hit no real LLM
or TTS box:
- /hosaka-audio.js is replaced by a fake `HosakaAudio` whose player records
every speak() request into window.__execSpoken (so no /ws/hosaka, no home
GPU box dependency — same trick as the tarot fake voice).
- /api/cc/query streams a canned SSE reply (history is empty).
- marked (web/vendor/) is a no-op shim.
Marked `browser` so the fast smoke step skips it; runs in pre-commit as a
dedicated step when the voice layer changes. Skips cleanly when playwright /
WebKit / the app are absent.
.venv/bin/playwright install webkit # once
.venv/bin/pytest tests/test_exec_voice_browser.py -q
"""
import pytest
# Skip the file cleanly when playwright isn't installed; the shared WebKit
# `browser` fixture (conftest.py) handles the browser-absent skip.
pytest.importorskip("playwright.sync_api")
pytestmark = pytest.mark.browser
def fulfill_js(js: str):
def handler(route):
route.fulfill(status=200, content_type="application/javascript", body=js)
return handler
def fulfill_sse(body: str):
def handler(route):
route.fulfill(status=200, content_type="text/event-stream", body=body)
return handler
_marked = fulfill_js("window.marked={use(){},parse:s=>s};")
# Fake HosakaAudio: a player that records speak() reqs instead of streaming PCM.
# unlock() flips the gesture flag so execVoice.ready() turns true after a tap,
# and counts itself: a voice that is off must never take the audio session.
_FAKE_HOSAKA = """
window.__execSpoken = [];
window.__unlocks = 0;
window.HosakaAudio = {
createPlayer: function (opts) {
var unlocked = false;
return {
unlock: function () { unlocked = true; window.__unlocks++; },
speak: function (req) { window.__execSpoken.push(req); return Promise.resolve(); },
flush: function () {},
setVolume: function () {},
elapsed: function () { return 0; },
audioDuration: function () { return 0; },
isUnlocked: function () { return unlocked; },
gestureUnlocked: function () { return unlocked; },
};
}
};
"""
# One assistant turn: bold markdown that must be flattened before TTS.
_REPLY_SSE = (
'data: {"type":"text","text":"Oh. It is **you**."}\n\n'
'data: {"type":"done"}\n\n'
)
# The session-scoped WebKit `browser` fixture lives in conftest.py, shared with
# the tarot browser suite (one sync_playwright per session — see the note there).
@pytest.fixture
def open_rd(browser, base_url, admin_headers):
"""Open /rd (the planning panel) with the TTS boundary + chat mocked."""
contexts = []
def _open(chat_sse=_REPLY_SSE):
ctx = browser.new_context(extra_http_headers=admin_headers)
contexts.append(ctx)
pg = ctx.new_page()
pg.route("**/hosaka-audio.js*", fulfill_js(_FAKE_HOSAKA))
pg.route("**/vendor/marked-*.min.js", _marked)
pg.route("**/api/cc/exec-history",
lambda r: r.fulfill(status=200, content_type="application/json",
body='{"messages":[],"monitorTotal":0}'))
pg.route("**/api/cc/query", fulfill_sse(chat_sse))
pg.goto(f"{base_url}/rd", wait_until="domcontentloaded")
pg.wait_for_selector("#exec-bubble", timeout=5000)
return pg
yield _open
for c in contexts:
try:
c.close()
except Exception:
pass
def _open_panel(pg):
# Opening the panel is NOT a request for audio: it must leave the session
# alone (it used to unlock, which paused Wai's music on every open).
pg.click("#exec-bubble")
pg.wait_for_selector("#exec-panel.open", timeout=4000)
assert not pg.evaluate("() => window.execVoice.ready()")
assert pg.evaluate("() => window.__unlocks") == 0
def _pass_gate(pg):
"""Exec has a line and no gesture yet: the tap-to-hear overlay asks."""
pg.wait_for_selector("#voice-gate:not([hidden])", timeout=8000)
pg.click("#voice-gate .vg-go")
assert pg.is_hidden("#voice-gate")
assert pg.evaluate("() => window.execVoice.ready()")
# A tap on the overlay is an answer to Exec, not a click outside the panel.
assert pg.is_visible("#exec-panel.open")
def _spoken(pg):
return pg.evaluate("() => window.__execSpoken || []")
def test_assistant_reply_is_spoken_in_glados(open_rd):
pg = open_rd()
_open_panel(pg)
pg.fill("#exec-minput", "hello")
pg.keyboard.press("Control+Enter") # bare Enter is a newline; 3 in a row send
_pass_gate(pg)
pg.wait_for_function("() => (window.__execSpoken || []).length >= 1", timeout=8000)
spoken = _spoken(pg)
assert len(spoken) == 1
req = spoken[0]
assert req["voice"] == "glados"
assert req["backend"] == "piper"
# markdown flattened, and the user's own text is never voiced
assert req["input"] == "Oh. It is you."
assert "hello" not in req["input"]
def test_speak_strips_markdown_and_brackets(open_rd):
pg = open_rd()
_open_panel(pg)
pg.evaluate("() => window.execVoice.speak('**bold** [ skip me ] words `code`')")
_pass_gate(pg)
pg.wait_for_function("() => (window.__execSpoken || []).length >= 1", timeout=4000)
assert _spoken(pg)[0]["input"] == "bold words code"
def test_speak_drops_a_table_and_keeps_snake_case_whole(open_rd):
"""Two things the agent answers with constantly. A table read aloud is a list of
pipes, and the old underscore-emphasis rule ate the middle of every
identifier -- `dir_start_min` came out as one spoken word."""
pg = open_rd()
_open_panel(pg)
pg.evaluate(r"""() => window.execVoice.speak(
'Edit dir_start_min here.\n\n| col | col |\n|---|---|\n| 1 | 2 |\n\nThen rerun.')""")
_pass_gate(pg)
pg.wait_for_function("() => (window.__execSpoken || []).length >= 1", timeout=4000)
said = _spoken(pg)[0]["input"]
assert "dir_start_min" in said
assert "|" not in said
assert said == "Edit dir_start_min here. Then rerun."
def test_muted_player_stays_silent(open_rd):
pg = open_rd()
_open_panel(pg)
pg.evaluate("() => window.execVoice.setOn(false)")
pg.evaluate("() => window.execVoice.speak('You should not hear this.')")
pg.wait_for_timeout(800)
assert _spoken(pg) == []
# Off asks for no tap and takes no audio session.
assert pg.query_selector("#voice-gate:not([hidden])") is None
assert pg.evaluate("() => window.__unlocks") == 0
def test_skip_drops_the_line_and_leaves_the_audio_alone(open_rd):
pg = open_rd()
_open_panel(pg)
pg.evaluate("() => window.execVoice.speak('Not now.')")
pg.wait_for_selector("#voice-gate:not([hidden])", timeout=4000)
pg.click("#voice-gate .vg-skip")
pg.wait_for_timeout(500)
assert _spoken(pg) == []
assert pg.evaluate("() => window.__unlocks") == 0
assert pg.is_visible("#exec-panel.open")
# ── voice off (was /cc's test; /cc folded into the panel 2026-09-29) ──────
def test_voice_off_stays_silent_and_still_shows_the_reply(open_rd):
"""OFF means off, not muted -- and the reply still types out at the silent
pace rather than waiting on audio that will never come."""
pg = open_rd()
_open_panel(pg)
assert pg.query_selector("#exec-mute.voice-mute") is not None
pg.evaluate("() => window.execVoice.setOn(false)")
pg.fill("#exec-minput", "quietly please")
pg.keyboard.press("Control+Enter") # bare Enter is a newline; 3 in a row send
pg.wait_for_function(
"""() => [...document.querySelectorAll('#exec-term .msg.assistant .msg-body')]
.some(b => b.innerText.includes('It is'))""", timeout=10000)
assert _spoken(pg) == []
assert pg.query_selector("#voice-gate:not([hidden])") is None
assert pg.evaluate("() => window.__unlocks") == 0