"""The transport seam. If the gr.HTML spike fails, only the stagePort changes. These tests exist so that stays true as the phase progresses. Plans 01-07 and 01-08 add turn behaviour; these tests fail if it lands in a transport file instead of in the shared turn-loop module. """ import re from pathlib import Path import pytest # Resolved from this file rather than the process cwd: the quick loop is documented as # running from the repo root, but a relative Path() would make that a silent # precondition of every assertion below. AVATAR = Path(__file__).resolve().parent.parent / "avatar" CORE = ["vrm-stage.js", "lipsync.js", "audio-queue.js"] SHARED = ["facade.js", "turn-loop.js"] TRANSPORTS = ["avatar.js", "avatar-iframe.js"] GRADIO_TOKENS = ["gradio", "gradio_api", "server.", "trigger("] AMPLITUDE_TOKENS = ["AnalyserNode", "getByteFrequencyData", "getFloatTimeDomainData"] TURN_SURFACE = ["startListening", "stopListening", "dispatchTurn", "requestSlower"] # Plan 01-07. Mic capture and ASR run in the PARENT document under both transports - # only rendering and audio playback live inside the iframe - so these modules must hang # off the shared turn loop, never off a transport. AUDIO_IN = ["mic.js", "asr.js"] # Wave 5. The host glue binds the page's controls to window.Avatar. It is loaded by the # shared boot template in avatar_component.py, so both transports get the same controls; # it must import no avatar module, be imported by none, and implement no turn behaviour. HOST = "host.js" COMPONENT = AVATAR.parent / "src" / "japanese_avatar" / "ui" / "avatar_component.py" def src(name: str) -> str: return (AVATAR / name).read_text(encoding="utf-8") def avatar_surface() -> list[str]: """Parse AVATAR_SURFACE out of facade.js. Never duplicate the list in the test.""" m = re.search(r"AVATAR_SURFACE\s*=\s*\[(.*?)\]", src("facade.js"), re.S) assert m, "AVATAR_SURFACE not found in avatar/facade.js" return re.findall(r"'([a-zA-Z]+)'", m.group(1)) @pytest.mark.parametrize("name", CORE) def test_stage_core_is_gradio_free(name): lowered = src(name).lower() for tok in GRADIO_TOKENS: assert tok.lower() not in lowered, ( f"{name} references {tok!r}; the stage core must stay transport-agnostic" ) @pytest.mark.parametrize("name", CORE) def test_no_amplitude_lipsync(name): for tok in AMPLITUDE_TOKENS: assert tok not in src(name), f"{name} uses {tok!r}; AVTR-02 disqualifies amplitude lip-sync" @pytest.mark.parametrize("name", SHARED) def test_shared_modules_are_gradio_free(name): lowered = src(name).lower() for tok in GRADIO_TOKENS: assert tok.lower() not in lowered, f"{name} must not know about Gradio" def test_lipsync_uses_audiocontext_clock(): s = src("lipsync.js") assert "currentTime" in s assert "setTimeout" not in s assert "requestAnimationFrame" not in s # the stage owns the loop; the player is a tick fn def test_module_urls_are_deps_pinned(): """The three runtime modules are the VENDORED, version-pinned builds (plan 01-10). Until 01-10 this asserted the esm.sh ``?deps=three@0.185.1`` URLs; the pin now lives in scripts/vendor_modules.py and the stage imports the files it wrote. Every one of the three must resolve relative to the module, so that the app (/gradio_api/file=avatar/...) and the standalone stage.html share one rule. """ s = src("vrm-stage.js") for name in ("three.mjs", "GLTFLoader.mjs", "three-vrm.mjs"): assert f"resolveModuleUrl('./vendor/{name}')" in s, f"{name} is not vendored-resolved" assert "esm.sh/" not in s, "a CDN URL is back in the render path" assert "import.meta.url" in s assert "importmap" not in s def test_only_facade_assigns_window_avatar(): """One facade builder. Two transports cannot drift if only one of them writes the object.""" for p in AVATAR.glob("*.js"): assigns = re.search(r"window\.Avatar\s*=", p.read_text(encoding="utf-8")) if p.name == "facade.js": assert assigns, "facade.js must be the one that installs window.Avatar" else: assert not assigns, f"{p.name} assigns window.Avatar; only facade.js may" @pytest.mark.parametrize("name", TRANSPORTS) def test_transports_only_do_plumbing(name): """A transport builds a stagePort and delegates. It never implements turn behaviour.""" s = src(name) assert "installFacade" in s and "createTurnLoop" in s, ( f"{name} must use the shared facade/turn loop" ) for member in TURN_SURFACE: assert f"function {member}" not in s and f"{member}(text" not in s, ( f"{name} implements {member}; turn behaviour belongs in avatar/turn-loop.js " "or the iframe fallback silently loses it" ) @pytest.mark.parametrize( "member", [ "mount", "speak", "replay", "setListening", "setThinking", "on", "getDebug", "startListening", "stopListening", "dispatchTurn", "requestSlower", "unlockAudio", ], ) def test_surface_member_is_declared_once(member): """Every promised method must resolve in facade.js's surface list.""" assert member in avatar_surface() @pytest.mark.parametrize("member", TURN_SURFACE) def test_turn_surface_lives_in_the_shared_module(member): assert member in src("turn-loop.js"), ( f"{member} must be defined in avatar/turn-loop.js so BOTH transports get it" ) @pytest.mark.parametrize( "key", [ "ready", "mountCount", "vrmMetaTitle", "threeInstanceCount", "currentVisemes", "blinkValue", "clockOffset", "transport", ], ) def test_debug_contract_keys_present(key): joined = src("facade.js") + src("turn-loop.js") + src("vrm-stage.js") assert key in joined, f"__debug.{key} is required by 01-VALIDATION.md Observable Signals" @pytest.mark.parametrize("name", AUDIO_IN) def test_audio_input_modules_are_gradio_free(name): lowered = src(name).lower() for tok in GRADIO_TOKENS: assert tok.lower() not in lowered, ( f"{name} references {tok!r}; mic capture and ASR must stay host-agnostic" ) @pytest.mark.parametrize("name", AUDIO_IN) def test_audio_input_modules_hang_off_the_turn_loop(name): """Imported by turn-loop.js, by neither transport. If a transport imported these directly, the other transport would silently lose push-to-talk - the exact failure mode the shared turn loop exists to prevent. """ assert f"./{name}" in src("turn-loop.js"), ( f"avatar/turn-loop.js must import {name} so BOTH transports get it at once" ) for transport in TRANSPORTS: assert name not in src(transport), ( f"{transport} imports {name}; mic/ASR wiring belongs in avatar/turn-loop.js" ) @pytest.mark.parametrize("name", AUDIO_IN) def test_audio_input_modules_do_not_do_amplitude_lipsync(name): """RMS in mic.js gates ASR. It must never become a lip-sync source (AVTR-02).""" for tok in AMPLITUDE_TOKENS: assert tok not in src(name), f"{name} uses {tok!r}; AVTR-02 disqualifies amplitude lip-sync" def test_blocklist_does_not_swallow_ordinary_japanese(): """The bare polite form is a thing a learner says out loud. Blocking it would be worse than the hallucination it prevents, and this guard exists because that entry is exactly what a later "helpful" edit would add. """ entries = re.findall(r"^\s*'([^']+)',\s*$", src("mic.js"), re.M) assert "ご視聴ありがとうございました" in entries, ( "the subtitle-boilerplate blocklist is missing its canonical entry" ) assert "ありがとうございました" not in entries, ( "the bare polite form is blocklisted; it is ordinary Japanese, not a hallucination" ) def test_gate_thresholds_match_the_measured_fixtures(): """The three constants the gate lives or dies by, pinned to docs/ASR-TIERS.md. Measured: cafe_noise_30s.wav modulates 1.957, speech_ja.wav modulates 10.716. A threshold above ~10 would reject real speech; below ~2.0 would pass the noise fixture. """ s = src("mic.js") assert "MIN_DURATION_MS: 300" in s assert "MIN_RMS: 0.01" in s assert "MIN_MODULATION: 2.5" in s assert "BLOCKLIST_MAX_MS: 1500" in s assert "REARM_TAIL_MS = 200" in s def test_no_remote_asr_path_exists(): """Tier D would burn the exact visitor GPU quota SC-4 tests the loop's survival without, so no hosted transcription path may exist anywhere in avatar/.""" for name in ["asr.js", "mic.js", "turn-loop.js"]: lowered = src(name).lower() for tok in ["zerogpu", "spaces.gpu", "/api/asr", "@spaces"]: assert tok not in lowered, ( f"{name} references {tok!r}; browser ASR must cost zero GPU quota" ) def test_asr_module_url_is_pinned(): s = src("asr.js") assert s.count("https://esm.sh/@huggingface/transformers@4.2.0") == 1, ( "exactly one pinned runtime URL, same discipline as vrm-stage.js" ) assert "importmap" not in s def test_asr_webgpu_fallback_is_real_not_aspirational(): """The fallback must probe for an ADAPTER, not merely for navigator.gpu. Measured on this stack: when navigator.gpu exists but requestAdapter() resolves to null, calling pipeline(..., {device:'webgpu'}) throws AND poisons the ONNX Runtime backend registry for the whole page, so the subsequent WASM re-instantiation fails with the same WebGPU error. Catch-and-retry alone is therefore not a working fallback; the doomed call must never be made. Deleting this assertion re-introduces a bug that only shows up on the machines the fallback exists for. """ s = src("asr.js") assert "navigator.gpu" in s assert "webgpu" in s assert "requestAdapter" in s, ( "avatar/asr.js checks navigator.gpu but never requests an adapter; that is the " "exact naive check that ships a broken app to every machine without a GPU" ) assert re.search(r"catch\s*\(", s), "no catch around the WebGPU init path" assert s.count("language: 'ja'") == 1 @pytest.mark.parametrize("key", ["asrTier", "asrModel", "lastTranscript", "micRejectedCount"]) def test_push_to_talk_debug_keys_present(key): assert key in src("turn-loop.js"), ( f"__debug.{key} is contributed by avatar/turn-loop.js, so both transports report it" ) @pytest.mark.parametrize("member", ["startListening", "stopListening"]) def test_push_to_talk_is_implemented_not_deferred(member): """The two methods this plan owns must no longer be notWiredYet stubs.""" s = src("turn-loop.js") assert f"notWiredYet('{member}'" not in s, ( f"{member} is still a deferred stub in avatar/turn-loop.js" ) assert f"async {member}(" in s, f"{member} must be implemented in avatar/turn-loop.js" @pytest.mark.parametrize("name", TRANSPORTS) def test_transports_gained_push_to_talk_without_gaining_code(name): """The whole point of the seam: the iframe fallback got this feature for free.""" s = src(name) for token in ["startListening", "stopListening", "mic.", "asr.", "createMic", "createAsr"]: assert token not in s, ( f"{name} mentions {token!r}; push-to-talk must live only in avatar/turn-loop.js" ) @pytest.mark.parametrize("member", ["dispatchTurn", "requestSlower"]) def test_turn_dispatch_is_implemented_not_deferred(member): """The wave-5 methods are real, and nothing in the file is a deferred stub any more.""" s = src("turn-loop.js") assert "notWiredYet" not in s, "a deferred stub survives in avatar/turn-loop.js" assert f"{member}(" in s, f"{member} must be implemented in avatar/turn-loop.js" def test_thinking_engages_before_the_first_await(): """VOIC-05's cheapest latency mitigation: the pose switches at dispatch, not at response. Verified by line number inside dispatchTurn: setThinking(true) precedes the first await, which is the server call. """ lines = src("turn-loop.js").splitlines() start = next(i for i, line in enumerate(lines) if "async function dispatchTurn(" in line) body = lines[start:] think_at = next(i for i, line in enumerate(body) if "setThinking(true)" in line) await_at = next(i for i, line in enumerate(body) if "await " in line) assert think_at < await_at, ( f"setThinking(true) is on dispatchTurn line {think_at} but the first await is on " f"line {await_at}; the thinking pose must engage before the server round trip" ) assert "await bridge." in body[await_at], body[await_at] def test_replay_is_networkless_by_construction(): """replay() re-emits the cached buffer. No fetch, no dynamic import, anywhere in the loop.""" s = src("turn-loop.js") assert "fetch(" not in s assert "import(" not in s assert "XMLHttpRequest" not in s def test_turn_loop_publishes_the_latency_numbers(): s = src("turn-loop.js") for key in ("lastTurnMs", "lastStageTimings", "lastSubtitle", "replayCount", "turnCount"): assert f"{key}:" in s, f"__debug.{key} is not initialised in avatar/turn-loop.js" for mark in ("turn:dispatch", "turn:response", "turn:speech-start"): assert f"performance.mark('{mark}')" in s, f"performance.mark({mark!r}) missing" assert "0.75" in s, "the slower speed must be the VOICEVOX speedScale 0.75" assert "greeting" in s def test_host_glue_is_neither_a_transport_nor_the_turn_loop(): """host.js is page glue: loaded from the shared boot template, importing nothing here.""" host = src(HOST) assert not re.search(r"^\s*import\s", host, re.M), ( "host.js must not import any avatar module; it is handed the facade" ) for member in TURN_SURFACE: assert f"function {member}" not in host and f"async {member}(" not in host, ( f"host.js implements {member}; turn behaviour belongs in avatar/turn-loop.js" ) for member in ["dispatchTurn", "requestSlower", "replay", "startListening", "stopListening"]: assert f".{member}(" in host, f"host.js never calls Avatar.{member}()" for name in [*TRANSPORTS, *SHARED, *CORE, *AUDIO_IN]: assert HOST not in src(name), f"{name} references {HOST}; only the boot template may" component = COMPONENT.read_text(encoding="utf-8") assert component.count(f"import('/gradio_api/file=avatar/{HOST}')") == 1, ( "the shared boot template must load host.js exactly once, for both transports" ) assert "bindHost(avatar, document)" in component # ------------------------------------------------------------------ plan 01-11: audio unlock # # The owner heard nothing on a phone. The only resume() on the turn path lived in # playBuffer, AFTER the server round trip and therefore outside the tap's user activation; # a gesture-gated browser leaves the context suspended, and every test had been masking it # with --autoplay-policy=no-user-gesture-required. These guards pin the fix statically; the # strict-policy browser tests prove it behaviourally at three layers. # The four turn-loop methods a gesture reaches, by the signature line each starts on. GESTURE_ENTRY_POINTS = [ "async function dispatchTurn(", "async replay()", "async requestSlower()", "async startListening()", ] UNLOCK_CALL = "stagePort.unlockAudio()" def _function_body(source: str, signature: str) -> list[str]: """The lines of the function whose signature line contains ``signature``, from the signature to its closing brace, found by brace counting from the signature's ``{``.""" lines = source.splitlines() start = next(i for i, line in enumerate(lines) if signature in line) depth = 0 opened = False for i in range(start, len(lines)): depth += lines[i].count("{") - lines[i].count("}") if "{" in lines[i]: opened = True if opened and depth == 0: return lines[start : i + 1] raise AssertionError(f"unbalanced braces after {signature!r}") def _statements(body: list[str]) -> list[str]: """The body's lines after the signature, minus blank lines and comment-only lines.""" out = [] for line in body[1:]: stripped = line.strip() if not stripped or stripped.startswith(("//", "/*", "*")): continue out.append(stripped) return out @pytest.mark.parametrize("name", TRANSPORTS) def test_stage_port_unlocks_audio_under_both_transports(name): """unlockAudio is a stagePort method on BOTH transports, and the iframe stage answers the message the iframe transport sends for it.""" assert "unlockAudio" in src(name), f"{name} builds a stagePort with no unlockAudio" assert "unlockAudio" in avatar_surface(), "unlockAudio is not in AVATAR_SURFACE" assert "'avatar:unlockAudio'" in src("avatar-iframe.js") assert "'avatar:unlockAudio'" in src("stage.html"), ( "avatar/stage.html has no handler for the iframe transport's unlock message" ) def test_inline_unlock_is_synchronous(): """The inline transport must CALL resume() synchronously, never await it: the call has to happen in the gesture's stack, and a blocked resume() promise never settles.""" body = _function_body(src("avatar.js"), "unlockAudio()") joined = "\n".join(body) assert "audioCtx.resume()" in joined assert "await" not in joined, "unlockAudio must not await; the CALL is the unlock" assert "return audioCtx.state" in joined @pytest.mark.parametrize("signature", GESTURE_ENTRY_POINTS) def test_turn_loop_unlocks_audio_before_its_first_await(signature): """Line-order proof, in the spirit of test_thinking_engages_before_the_first_await: the unlock is the FIRST statement of every gesture-reachable entry point, so it runs inside the gesture's call stack - anything after an await does not.""" body = _function_body(src("turn-loop.js"), signature) statements = _statements(body) assert statements and statements[0].startswith(UNLOCK_CALL), ( f"{signature} does not start with {UNLOCK_CALL}; first statement is {statements[:1]}" ) awaits = [i for i, line in enumerate(statements) if "await " in line] if awaits: assert awaits[0] > 0, f"{signature} awaits before it unlocks" def test_turn_loop_unlock_count(): assert src("turn-loop.js").count(UNLOCK_CALL) >= 4 def test_host_unlocks_audio_in_every_gesture_handler(): """Belt and braces: the host unlocks first thing in all six gesture handlers, so a gesture that never reaches the loop (Send with an empty box) still unlocks.""" host = src(HOST) assert host.count("avatar.unlockAudio()") >= 6, ( f"host.js calls avatar.unlockAudio() {host.count('avatar.unlockAudio()')} time(s); " "every gesture handler (send, Enter, hello, replay, slower, ptt) must call it first" ) for handler in ("'click'", "'keydown'", "'pointerdown'"): assert handler in host def test_audio_queue_refuses_to_fake_speech_on_a_stopped_clock(): """playBuffer must check the context is running BEFORE it emits speech-start, and must report the blocked state as an 'error' rather than scheduling against a dead clock.""" s = src("audio-queue.js") assert "state !== 'running'" in s assert s.index("state !== 'running'") < s.index("emit('speech-start'"), ( "audio-queue.js emits speech-start before it checks audioCtx.state" ) assert "where: 'audio'" in s # A blocked resume() promise never settles in Chromium; the wait must be bounded. assert "Promise.race" in s and "RESUME_GRACE_MS" in s @pytest.mark.parametrize("name", ["avatar.js", "stage.html", "facade.js"]) def test_audio_state_is_published(name): """audioState is a published field on getDebug() from both transports and the standalone harness, so the strict-policy tests assert a number, not a silence.""" assert "audioState" in src(name), f"{name} does not publish audioState"