Ctrl K
ComunidadGuías…

tests/test_exec_voice_browser.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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