"""Browser-suite plumbing: a static server for the standalone harness and a real Gradio app subprocess per transport for the parity proof. Both suites run entirely locally. They must be green BEFORE plan 01-05 deploys anything, because that is what makes 01-05's verdict interpretable: if standalone passes and deployed fails, the problem is the host, not three.js. """ from __future__ import annotations import functools import http.server import os import socket import subprocess import sys import threading import time import urllib.error import urllib.request from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parent.parent.parent APP_BOOT_TIMEOUT_S = 180 def _free_port() -> int: with socket.socket() as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] @pytest.fixture(scope="session") def browser_type_launch_args(browser_type_launch_args): """Chromium's autoplay policy leaves an ungestured AudioContext suspended forever. A suspended context never advances currentTime, so the viseme player would never tick and 'speech-end' would never fire - the test would be measuring the autoplay policy rather than the lip-sync. The flag is the standard way to opt out. """ return { **browser_type_launch_args, "args": [ *browser_type_launch_args.get("args", []), "--autoplay-policy=no-user-gesture-required", ], } @pytest.fixture(scope="session") def static_server() -> str: """Serve the repo root so /avatar/stage.html and /avatar/assets/tutor.vrm resolve.""" handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT)) server = http.server.ThreadingHTTPServer(("127.0.0.1", _free_port()), handler) server.daemon_threads = True threading.Thread(target=server.serve_forever, daemon=True).start() try: yield f"http://127.0.0.1:{server.server_port}" finally: server.shutdown() server.server_close() def _wait_for_http(url: str, proc: subprocess.Popen, timeout_s: int) -> None: deadline = time.time() + timeout_s while time.time() < deadline: if proc.poll() is not None: out = proc.stdout.read() if proc.stdout else "" raise RuntimeError(f"app.py exited with {proc.returncode}:\n{out}") try: with urllib.request.urlopen(url, timeout=2) as response: if response.status == 200: return except (urllib.error.URLError, TimeoutError, ConnectionError, OSError): time.sleep(0.5) raise RuntimeError(f"app.py did not answer {url} within {timeout_s}s") @pytest.fixture(scope="session") def gradio_apps(): """Start app.py once per AVATAR_TRANSPORT value and return its base URL. One env var is the entire difference between the two deployments, which is the property the parity suite exists to prove. """ started: dict[str, tuple[str, subprocess.Popen]] = {} def start(transport: str) -> str: if transport not in started: port = _free_port() env = { **os.environ, "AVATAR_TRANSPORT": transport, "GRADIO_SERVER_NAME": "127.0.0.1", "GRADIO_SERVER_PORT": str(port), "GRADIO_ANALYTICS_ENABLED": "False", } proc = subprocess.Popen( # noqa: S603 [sys.executable, "app.py"], cwd=str(REPO_ROOT), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) url = f"http://127.0.0.1:{port}/" try: _wait_for_http(url, proc, APP_BOOT_TIMEOUT_S) except Exception: proc.kill() raise started[transport] = (url, proc) return started[transport][0] try: yield start finally: for _url, proc in started.values(): proc.terminate() try: proc.wait(timeout=15) except subprocess.TimeoutExpired: proc.kill()