Spaces:
Running on Zero
test(01-07): prove the gate, the tier fallback and push-to-talk under both transports
Browse files- tests/e2e/test_asr_standalone.py: 7 local browser tests driving Chromium with each
fixture as its microphone. Silence and cafe noise produce zero transcripts between
them, and the cafe rejection is asserted on the envelope-modulation condition
specifically, so the gate cannot silently decay into an RMS floor
- the suite runs the harness with browser audio processing OFF: with noise suppression
on, Chromium drops the cafe fixture to 0.0055 RMS and the modulation condition is
never reached, which would be green and meaningless
- test_tier_wasm_fallback removes navigator.gpu outright, because the launch flag alone
leaves the API surface in place in Chromium 151
- test_facade_parity.py: the deferred-method test now scopes to plan 01-08's two
remaining stubs rather than being deleted, plus a new case asserting startListening
and stopListening exist and no longer throw under BOTH transports
- tests/e2e/test_asr_standalone.py +358 -0
- tests/e2e/test_facade_parity.py +49 -2
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Push-to-talk, the pre-ASR gate and the tier fallback, proven in a real browser.
|
| 2 |
+
|
| 3 |
+
Local counterparts of three rows in 01-VALIDATION.md. Those rows' names -
|
| 4 |
+
``test_ptt_turn``, ``test_silence_rejected``, ``test_asr_wasm_fallback`` - belong to the
|
| 5 |
+
DEPLOYED suite in plan 01-09 and are deliberately not reused here, so the phase verifier
|
| 6 |
+
cannot mistake a local pass for a deployed one. Everything below runs against a static
|
| 7 |
+
server on loopback and needs no Space.
|
| 8 |
+
|
| 9 |
+
Two things about the environment shape these tests, and both were measured rather than
|
| 10 |
+
assumed:
|
| 11 |
+
|
| 12 |
+
1. **Chromium's WebRTC audio processing is very good at steady noise.** With
|
| 13 |
+
``noiseSuppression`` on, the committed cafe fixture arrives at RMS 0.0055 instead of
|
| 14 |
+
0.0577 and is rejected by the RMS floor before the envelope-modulation condition is
|
| 15 |
+
ever consulted. Green, and meaningless. The harness is therefore driven with
|
| 16 |
+
``?processing=off`` so the gate is verified in the PESSIMISTIC configuration - a raw
|
| 17 |
+
microphone, which is what a browser without WebRTC processing hands us anyway.
|
| 18 |
+
2. **Headless Chromium exposes ``navigator.gpu`` but returns a null adapter.** So the
|
| 19 |
+
default launch already exercises the branch that matters most - the API is present,
|
| 20 |
+
the adapter probe says no, and the runtime must never issue the WebGPU call that would
|
| 21 |
+
poison it for the rest of the page. ``test_tier_wasm_fallback`` covers the other
|
| 22 |
+
branch, where the WebGPU API is absent entirely.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import functools
|
| 28 |
+
import http.server
|
| 29 |
+
import socket
|
| 30 |
+
import tempfile
|
| 31 |
+
import threading
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
import pytest
|
| 35 |
+
|
| 36 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 37 |
+
FIXTURES = REPO_ROOT / "tests" / "fixtures"
|
| 38 |
+
|
| 39 |
+
# Driven with the browser's own audio processing disabled - see the module docstring.
|
| 40 |
+
HARNESS = "/avatar/asr-harness.html?processing=off"
|
| 41 |
+
HARNESS_READY = "() => window.__harnessReady === true"
|
| 42 |
+
READY_TIMEOUT_MS = 60_000
|
| 43 |
+
# A first whisper-base q4 load is tens of megabytes over the network plus session build.
|
| 44 |
+
MODEL_TIMEOUT_MS = 600_000
|
| 45 |
+
|
| 46 |
+
# A fixed port keeps the browser Cache API origin stable between runs, which is the only
|
| 47 |
+
# reason the model files survive from one invocation to the next.
|
| 48 |
+
PREFERRED_PORT = 8478
|
| 49 |
+
|
| 50 |
+
# The model files live in the browser profile, so a stable profile directory is what
|
| 51 |
+
# turns the second run of this suite from a download into a disk read.
|
| 52 |
+
MODEL_PROFILE = Path(tempfile.gettempdir()) / "jla-asr-chromium-profile"
|
| 53 |
+
|
| 54 |
+
BASE_ARGS = [
|
| 55 |
+
"--autoplay-policy=no-user-gesture-required",
|
| 56 |
+
"--use-fake-ui-for-media-stream",
|
| 57 |
+
"--use-fake-device-for-media-stream",
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
# Chromium 151 keeps navigator.gpu defined even with WebGPU disabled by launch flag: it
|
| 61 |
+
# stops an adapter being handed out but leaves the API surface in place. Since the whole
|
| 62 |
+
# point of test_tier_wasm_fallback is the branch where the API is ABSENT - a Firefox
|
| 63 |
+
# before 141, a Safari before 26 - the property is removed in the page as well.
|
| 64 |
+
# A bare statement, not an arrow function: add_init_script evaluates the string, so a
|
| 65 |
+
# function expression would be constructed and thrown away without ever running.
|
| 66 |
+
HIDE_WEBGPU = "try { delete Navigator.prototype.gpu; } catch (e) {}"
|
| 67 |
+
|
| 68 |
+
# Reject reasons, mirrored from avatar/mic.js REJECT. Asserting on the specific condition
|
| 69 |
+
# rather than merely "it was rejected" is what stops the gate silently degrading into an
|
| 70 |
+
# RMS floor the day someone loosens the modulation threshold.
|
| 71 |
+
REASON_DURATION = "duration-floor"
|
| 72 |
+
REASON_RMS = "rms-floor"
|
| 73 |
+
REASON_MODULATION = "envelope-modulation"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def audio_arg(wav: str) -> str:
|
| 77 |
+
"""Chromium's fake audio capture wants 16-bit PCM WAV; the fixtures already are."""
|
| 78 |
+
return f"--use-file-for-fake-audio-capture={(FIXTURES / wav).as_posix()}%noloop"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _free_port() -> int:
|
| 82 |
+
with socket.socket() as s:
|
| 83 |
+
s.bind(("127.0.0.1", 0))
|
| 84 |
+
return s.getsockname()[1]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@pytest.fixture(scope="module")
|
| 88 |
+
def asr_server() -> str:
|
| 89 |
+
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT))
|
| 90 |
+
try:
|
| 91 |
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", PREFERRED_PORT), handler)
|
| 92 |
+
except OSError:
|
| 93 |
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", _free_port()), handler)
|
| 94 |
+
server.daemon_threads = True
|
| 95 |
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
| 96 |
+
try:
|
| 97 |
+
yield f"http://127.0.0.1:{server.server_port}"
|
| 98 |
+
finally:
|
| 99 |
+
server.shutdown()
|
| 100 |
+
server.server_close()
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
CAPTURE = """
|
| 104 |
+
async () => ({
|
| 105 |
+
transcripts: window.__events('transcript').map((e) => e.data),
|
| 106 |
+
tiers: window.__events('asr-tier').map((e) => e.data),
|
| 107 |
+
listening: window.__events('listening').map((e) => e.data),
|
| 108 |
+
errors: window.__events('error').map((e) => e.data),
|
| 109 |
+
pageErrors: [...window.__pageErrors],
|
| 110 |
+
asr: window.__asrDebug(),
|
| 111 |
+
})
|
| 112 |
+
"""
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _run_push(
|
| 116 |
+
playwright, server: str, wav: str, hold_ms: int, extra_args=(), cached=False, hide_webgpu=False
|
| 117 |
+
):
|
| 118 |
+
"""One push-to-talk cycle in a browser fed `wav` as its microphone."""
|
| 119 |
+
args = [*BASE_ARGS, audio_arg(wav), *extra_args]
|
| 120 |
+
console: list[str] = []
|
| 121 |
+
if cached:
|
| 122 |
+
MODEL_PROFILE.mkdir(parents=True, exist_ok=True)
|
| 123 |
+
context = playwright.chromium.launch_persistent_context(
|
| 124 |
+
user_data_dir=str(MODEL_PROFILE), args=args
|
| 125 |
+
)
|
| 126 |
+
browser = None
|
| 127 |
+
else:
|
| 128 |
+
browser = playwright.chromium.launch(args=args)
|
| 129 |
+
context = browser.new_context()
|
| 130 |
+
try:
|
| 131 |
+
if hide_webgpu:
|
| 132 |
+
context.add_init_script(HIDE_WEBGPU)
|
| 133 |
+
page = context.new_page()
|
| 134 |
+
page.on("console", lambda m: console.append(f"{m.type}: {m.text}"))
|
| 135 |
+
page.on("pageerror", lambda e: console.append(f"pageerror: {e}"))
|
| 136 |
+
page.set_default_timeout(MODEL_TIMEOUT_MS)
|
| 137 |
+
page.goto(f"{server}{HARNESS}")
|
| 138 |
+
page.wait_for_function(HARNESS_READY, timeout=READY_TIMEOUT_MS)
|
| 139 |
+
pushed = page.evaluate("async (hold) => await window.__push(hold)", hold_ms)
|
| 140 |
+
captured = page.evaluate(CAPTURE)
|
| 141 |
+
return {**pushed, **captured, "console": console}
|
| 142 |
+
finally:
|
| 143 |
+
context.close()
|
| 144 |
+
if browser is not None:
|
| 145 |
+
browser.close()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@pytest.fixture(scope="module")
|
| 149 |
+
def silence_push(playwright, asr_server):
|
| 150 |
+
return _run_push(playwright, asr_server, "silence_30s.wav", 3000)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@pytest.fixture(scope="module")
|
| 154 |
+
def cafe_push(playwright, asr_server):
|
| 155 |
+
return _run_push(playwright, asr_server, "cafe_noise_30s.wav", 3000)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@pytest.fixture(scope="module")
|
| 159 |
+
def short_push(playwright, asr_server):
|
| 160 |
+
return _run_push(playwright, asr_server, "speech_ja.wav", 150)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@pytest.fixture(scope="module")
|
| 164 |
+
def speech_push(playwright, asr_server):
|
| 165 |
+
"""The only fixture that loads a model on the default (WebGPU-attempted) launch."""
|
| 166 |
+
return _run_push(playwright, asr_server, "speech_ja.wav", 1400, cached=True)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@pytest.fixture(scope="module")
|
| 170 |
+
def no_webgpu_push(playwright, asr_server):
|
| 171 |
+
"""WebGPU forcibly off: navigator.gpu is absent, so no attempt is even made."""
|
| 172 |
+
return _run_push(
|
| 173 |
+
playwright,
|
| 174 |
+
asr_server,
|
| 175 |
+
"speech_ja.wav",
|
| 176 |
+
1400,
|
| 177 |
+
extra_args=["--disable-features=WebGPU", "--disable-gpu"],
|
| 178 |
+
hide_webgpu=True,
|
| 179 |
+
cached=True,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@pytest.fixture(scope="module")
|
| 184 |
+
def blocklist_page(playwright, asr_server):
|
| 185 |
+
"""No microphone and no model: the blocklist is a pure function of text + duration."""
|
| 186 |
+
browser = playwright.chromium.launch(args=BASE_ARGS)
|
| 187 |
+
try:
|
| 188 |
+
page = browser.new_page()
|
| 189 |
+
page.goto(f"{asr_server}{HARNESS}")
|
| 190 |
+
page.wait_for_function(HARNESS_READY, timeout=READY_TIMEOUT_MS)
|
| 191 |
+
yield page
|
| 192 |
+
finally:
|
| 193 |
+
browser.close()
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def test_gate_rejects_silence(silence_push):
|
| 197 |
+
"""30 s of silence must not become an utterance. The RMS floor is what catches it."""
|
| 198 |
+
mic = silence_push["mic"]
|
| 199 |
+
assert silence_push["started"] is True, "capture never started, so nothing was gated"
|
| 200 |
+
assert mic["lastRejectReason"] == REASON_RMS, (
|
| 201 |
+
f"silence was rejected for {mic['lastRejectReason']!r}, not the RMS floor; "
|
| 202 |
+
f"measured rms={mic['lastRms']:.5f} over {mic['lastDurationMs']:.0f} ms"
|
| 203 |
+
)
|
| 204 |
+
assert mic["acceptedCount"] == 0
|
| 205 |
+
assert mic["rejectedCount"] == 1
|
| 206 |
+
assert silence_push["transcripts"] == [], (
|
| 207 |
+
"a transcript escaped from silence; this is the Whisper-hallucination failure "
|
| 208 |
+
f"the gate exists to prevent: {silence_push['transcripts']}"
|
| 209 |
+
)
|
| 210 |
+
assert silence_push["text"] is None
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test_gate_rejects_cafe_noise(cafe_push, silence_push):
|
| 214 |
+
"""The assertion that proves the gate is more than an RMS floor.
|
| 215 |
+
|
| 216 |
+
The cafe fixture is written at -24.8 dBFS specifically so it sails past any plausible
|
| 217 |
+
RMS threshold. It is rejected because steady broadband noise has no envelope
|
| 218 |
+
modulation, which is the only property that actually distinguishes it from speech.
|
| 219 |
+
"""
|
| 220 |
+
mic = cafe_push["mic"]
|
| 221 |
+
assert cafe_push["started"] is True
|
| 222 |
+
assert mic["lastRms"] > 0.01, (
|
| 223 |
+
f"the cafe fixture arrived at rms={mic['lastRms']:.5f}, below the RMS floor, so "
|
| 224 |
+
"the modulation condition was never reached and this test proves nothing"
|
| 225 |
+
)
|
| 226 |
+
assert mic["lastRejectReason"] == REASON_MODULATION, (
|
| 227 |
+
f"cafe noise was rejected for {mic['lastRejectReason']!r}; the envelope-modulation "
|
| 228 |
+
f"condition is the one that must fire. measured modulation={mic['lastModulation']:.3f}"
|
| 229 |
+
)
|
| 230 |
+
assert mic["lastModulation"] < 2.5
|
| 231 |
+
assert mic["acceptedCount"] == 0
|
| 232 |
+
assert cafe_push["transcripts"] == []
|
| 233 |
+
|
| 234 |
+
# 01-VALIDATION.md VOIC-02: silence and noise produce ZERO avatar turns, not few.
|
| 235 |
+
assert len(cafe_push["transcripts"]) + len(silence_push["transcripts"]) == 0
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def test_gate_rejects_short_push(short_push):
|
| 239 |
+
"""A stray tap on the control is not an utterance.
|
| 240 |
+
|
| 241 |
+
A 150 ms hold does not survive getUserMedia's own start-up latency, so the recording
|
| 242 |
+
is empty - which is a zero-length recording, i.e. the duration floor, not a separate
|
| 243 |
+
failure mode nobody could act on.
|
| 244 |
+
"""
|
| 245 |
+
mic = short_push["mic"]
|
| 246 |
+
assert mic["lastRejectReason"] == REASON_DURATION, (
|
| 247 |
+
f"a 150 ms push was rejected for {mic['lastRejectReason']!r}, not the duration floor"
|
| 248 |
+
)
|
| 249 |
+
assert mic["lastDurationMs"] < 300
|
| 250 |
+
assert mic["acceptedCount"] == 0
|
| 251 |
+
assert short_push["transcripts"] == []
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@pytest.mark.slow
|
| 255 |
+
def test_gate_accepts_speech(speech_push):
|
| 256 |
+
"""The other half of the gate: real Japanese must get through and be transcribed."""
|
| 257 |
+
mic = speech_push["mic"]
|
| 258 |
+
assert mic["lastRejectReason"] is None, (
|
| 259 |
+
f"speech_ja.wav was rejected for {mic['lastRejectReason']!r}; "
|
| 260 |
+
f"rms={mic['lastRms']:.5f} modulation={mic['lastModulation']:.3f}"
|
| 261 |
+
)
|
| 262 |
+
assert mic["acceptedCount"] == 1
|
| 263 |
+
assert mic["lastModulation"] >= 2.5
|
| 264 |
+
assert len(speech_push["transcripts"]) == 1, (
|
| 265 |
+
f"expected exactly one transcript, got {speech_push['transcripts']}"
|
| 266 |
+
)
|
| 267 |
+
event = speech_push["transcripts"][0]
|
| 268 |
+
assert event["text"].strip(), "the transcript event fired with empty text"
|
| 269 |
+
assert event["gated"] is False
|
| 270 |
+
assert event["tier"] in {"webgpu", "wasm"}
|
| 271 |
+
assert speech_push["text"] == event["text"]
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
@pytest.mark.slow
|
| 275 |
+
def test_tier_reports_active_backend(speech_push, record_property):
|
| 276 |
+
"""Announcing the active tier is a product requirement, not debug output.
|
| 277 |
+
|
| 278 |
+
Headless Chromium exposes navigator.gpu but hands back a null adapter, so this run
|
| 279 |
+
also proves the catch-and-re-instantiate path is real: the attempt is made, it fails,
|
| 280 |
+
the failure is recorded, and the user still gets a transcript.
|
| 281 |
+
"""
|
| 282 |
+
tiers = speech_push["tiers"]
|
| 283 |
+
assert len(tiers) == 1, f"expected exactly one asr-tier announcement, got {tiers}"
|
| 284 |
+
tier = tiers[0]
|
| 285 |
+
assert tier["tier"] in {"webgpu", "wasm"}
|
| 286 |
+
assert tier["loadMs"] > 0
|
| 287 |
+
assert tier["model"] and tier["dtype"]
|
| 288 |
+
record_property("asr_tier", tier["tier"])
|
| 289 |
+
record_property("asr_load_ms", tier["loadMs"])
|
| 290 |
+
|
| 291 |
+
asr = speech_push["asr"]
|
| 292 |
+
if asr["webgpuPresent"] and tier["tier"] == "wasm":
|
| 293 |
+
assert asr["webgpuAvailable"] is False, (
|
| 294 |
+
"the WASM tier was chosen while an adapter was available; the tier report is "
|
| 295 |
+
"not describing what actually ran"
|
| 296 |
+
)
|
| 297 |
+
assert asr["webgpuError"], (
|
| 298 |
+
"navigator.gpu was present and the WASM tier was chosen, but no reason was "
|
| 299 |
+
"recorded - the fallback happened for an unexplained reason"
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@pytest.mark.slow
|
| 304 |
+
def test_tier_wasm_fallback(no_webgpu_push, record_property):
|
| 305 |
+
"""The local rehearsal of the deployed test_asr_wasm_fallback.
|
| 306 |
+
|
| 307 |
+
WebGPU forcibly off. The tier must be wasm, transcription must still work, and no
|
| 308 |
+
uncaught exception may reach the page - a broken app is not an acceptable degradation.
|
| 309 |
+
"""
|
| 310 |
+
asr = no_webgpu_push["asr"]
|
| 311 |
+
assert asr["webgpuPresent"] is False, (
|
| 312 |
+
"navigator.gpu survived into the page, so this run is not actually testing the "
|
| 313 |
+
"branch where the WebGPU API is absent"
|
| 314 |
+
)
|
| 315 |
+
assert asr["webgpuAvailable"] is False
|
| 316 |
+
tiers = no_webgpu_push["tiers"]
|
| 317 |
+
assert len(tiers) == 1 and tiers[0]["tier"] == "wasm", f"expected the WASM tier, got {tiers}"
|
| 318 |
+
assert tiers[0]["loadMs"] > 0
|
| 319 |
+
record_property("wasm_load_ms", tiers[0]["loadMs"])
|
| 320 |
+
|
| 321 |
+
assert no_webgpu_push["pageErrors"] == [], (
|
| 322 |
+
f"an uncaught exception reached the page on the fallback path: "
|
| 323 |
+
f"{no_webgpu_push['pageErrors']}"
|
| 324 |
+
)
|
| 325 |
+
assert no_webgpu_push["errors"] == [], (
|
| 326 |
+
f"the facade emitted error events on the fallback path: {no_webgpu_push['errors']}"
|
| 327 |
+
)
|
| 328 |
+
assert len(no_webgpu_push["transcripts"]) == 1, (
|
| 329 |
+
f"WASM-only transcription produced {no_webgpu_push['transcripts']}"
|
| 330 |
+
)
|
| 331 |
+
assert no_webgpu_push["transcripts"][0]["text"].strip()
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def test_blocklist_only_applies_to_short_pushes(blocklist_page):
|
| 335 |
+
"""The blocklist must not swallow ordinary Japanese.
|
| 336 |
+
|
| 337 |
+
The bare polite form is a thing a learner says. The subtitle-boilerplate form is not,
|
| 338 |
+
but over 1.5 s of audio even that is more likely to be a real sentence than a
|
| 339 |
+
hallucination, so the blocklist stops applying.
|
| 340 |
+
"""
|
| 341 |
+
ordinary = "γγγγ¨γγγγγΎγγ"
|
| 342 |
+
boilerplate = "γθ¦θ΄" + ordinary
|
| 343 |
+
|
| 344 |
+
def check(text: str, duration_ms: int) -> bool:
|
| 345 |
+
return blocklist_page.evaluate(
|
| 346 |
+
"([t, d]) => window.__isHallucination(t, d)", [text, duration_ms]
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
assert check(ordinary, 500) is False, "the bare polite form was blocklisted"
|
| 350 |
+
assert check(ordinary, 5000) is False
|
| 351 |
+
assert check(boilerplate, 500) is True, "subtitle boilerplate passed on a short push"
|
| 352 |
+
assert check(boilerplate, 1499) is True
|
| 353 |
+
assert check(boilerplate, 1500) is False, (
|
| 354 |
+
"the blocklist still applied at 1.5 s; past that length the string is far more "
|
| 355 |
+
"likely to be something the learner actually said"
|
| 356 |
+
)
|
| 357 |
+
# Trailing Japanese punctuation must not let boilerplate through.
|
| 358 |
+
assert check(boilerplate + "γ", 500) is True
|
|
@@ -23,9 +23,18 @@ BOOT_TIMEOUT_MS = 120_000
|
|
| 23 |
|
| 24 |
# Deliberately double-quoted and built from data: the surface list must come from
|
| 25 |
# facade.js, never from a literal in this file.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
DEFERRED_PLAN = "01-08"
|
| 27 |
DEFERRED_METHODS = ("dispatchTurn", "requestSlower")
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
CALL_DEFERRED = """
|
| 30 |
async (name) => {
|
| 31 |
try {
|
|
@@ -37,6 +46,23 @@ async (name) => {
|
|
| 37 |
}
|
| 38 |
"""
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
@pytest.fixture(scope="session")
|
| 42 |
def live_avatars(browser, gradio_apps):
|
|
@@ -57,6 +83,7 @@ def live_avatars(browser, gradio_apps):
|
|
| 57 |
),
|
| 58 |
"reported_transport": page.evaluate("() => window.Avatar.__debug.transport"),
|
| 59 |
"deferred": {name: page.evaluate(CALL_DEFERRED, name) for name in DEFERRED_METHODS},
|
|
|
|
| 60 |
}
|
| 61 |
finally:
|
| 62 |
page.close()
|
|
@@ -90,9 +117,11 @@ def test_transports_expose_identical_debug_keys(live_avatars):
|
|
| 90 |
|
| 91 |
|
| 92 |
def test_deferred_methods_fail_loudly_not_silently(live_avatars):
|
| 93 |
-
"""The stubs are placeholders, not accidental no-ops.
|
| 94 |
|
| 95 |
-
|
|
|
|
|
|
|
| 96 |
"""
|
| 97 |
for transport in TRANSPORTS:
|
| 98 |
for name, outcome in live_avatars[transport]["deferred"].items():
|
|
@@ -104,3 +133,21 @@ def test_deferred_methods_fail_loudly_not_silently(live_avatars):
|
|
| 104 |
f"{transport}: Avatar.{name}() threw {outcome['message']!r}, which does "
|
| 105 |
f"not name plan {DEFERRED_PLAN}"
|
| 106 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
# Deliberately double-quoted and built from data: the surface list must come from
|
| 25 |
# facade.js, never from a literal in this file.
|
| 26 |
+
#
|
| 27 |
+
# This tuple shrinks as waves land. Plan 01-07 implemented startListening/stopListening,
|
| 28 |
+
# so they moved OUT of here and into WIRED_METHODS below; only plan 01-08's two remain.
|
| 29 |
+
# The test itself is not deleted until the tuple is empty - a deferred stub that has
|
| 30 |
+
# quietly become a no-op is exactly the drift this suite exists to catch.
|
| 31 |
DEFERRED_PLAN = "01-08"
|
| 32 |
DEFERRED_METHODS = ("dispatchTurn", "requestSlower")
|
| 33 |
|
| 34 |
+
# Implemented and expected to work under BOTH transports.
|
| 35 |
+
WIRED_PLAN = "01-07"
|
| 36 |
+
WIRED_METHODS = ("startListening", "stopListening")
|
| 37 |
+
|
| 38 |
CALL_DEFERRED = """
|
| 39 |
async (name) => {
|
| 40 |
try {
|
|
|
|
| 46 |
}
|
| 47 |
"""
|
| 48 |
|
| 49 |
+
# Type check plus a real call. A method that exists but still throws the not-wired error
|
| 50 |
+
# would pass a typeof check and fail a learner, so both halves are needed.
|
| 51 |
+
PROBE_WIRED = """
|
| 52 |
+
async (name) => {
|
| 53 |
+
const isFunction = typeof window.Avatar[name] === 'function';
|
| 54 |
+
let notWired = false;
|
| 55 |
+
let message = '';
|
| 56 |
+
try {
|
| 57 |
+
await window.Avatar[name]();
|
| 58 |
+
} catch (err) {
|
| 59 |
+
message = String((err && err.message) || err);
|
| 60 |
+
notWired = message.includes('not wired yet');
|
| 61 |
+
}
|
| 62 |
+
return { isFunction, notWired, message };
|
| 63 |
+
}
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
|
| 67 |
@pytest.fixture(scope="session")
|
| 68 |
def live_avatars(browser, gradio_apps):
|
|
|
|
| 83 |
),
|
| 84 |
"reported_transport": page.evaluate("() => window.Avatar.__debug.transport"),
|
| 85 |
"deferred": {name: page.evaluate(CALL_DEFERRED, name) for name in DEFERRED_METHODS},
|
| 86 |
+
"wired": {name: page.evaluate(PROBE_WIRED, name) for name in WIRED_METHODS},
|
| 87 |
}
|
| 88 |
finally:
|
| 89 |
page.close()
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
def test_deferred_methods_fail_loudly_not_silently(live_avatars):
|
| 120 |
+
"""The remaining stubs are placeholders, not accidental no-ops.
|
| 121 |
|
| 122 |
+
Scope shrinks with each wave rather than the test being deleted: plan 01-07 removed
|
| 123 |
+
startListening/stopListening from DEFERRED_METHODS when it implemented them, and plan
|
| 124 |
+
01-08 empties the tuple. Whatever is still deferred must still fail loudly.
|
| 125 |
"""
|
| 126 |
for transport in TRANSPORTS:
|
| 127 |
for name, outcome in live_avatars[transport]["deferred"].items():
|
|
|
|
| 133 |
f"{transport}: Avatar.{name}() threw {outcome['message']!r}, which does "
|
| 134 |
f"not name plan {DEFERRED_PLAN}"
|
| 135 |
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_push_to_talk_exists_under_both_transports(live_avatars):
|
| 139 |
+
"""The payoff of putting the wiring in avatar/turn-loop.js.
|
| 140 |
+
|
| 141 |
+
The iframe transport gained push-to-talk without gaining a line of code, and this is
|
| 142 |
+
the assertion that proves it against a LIVE object rather than against a grep.
|
| 143 |
+
"""
|
| 144 |
+
for transport in TRANSPORTS:
|
| 145 |
+
for name, outcome in live_avatars[transport]["wired"].items():
|
| 146 |
+
assert outcome["isFunction"], (
|
| 147 |
+
f"{transport}: Avatar.{name} is not a function; the shared turn loop did "
|
| 148 |
+
"not reach this transport"
|
| 149 |
+
)
|
| 150 |
+
assert not outcome["notWired"], (
|
| 151 |
+
f"{transport}: Avatar.{name}() still throws the plan {WIRED_PLAN} "
|
| 152 |
+
f"not-wired error: {outcome['message']!r}"
|
| 153 |
+
)
|