File size: 15,101 Bytes
f2435d2
 
 
d6ea280
f2435d2
 
 
 
 
 
 
 
d6ea280
 
 
 
f2435d2
d6ea280
 
c23e6ce
d6ea280
 
 
 
f2435d2
d6ea280
 
 
 
 
 
 
 
 
 
 
f2435d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6ea280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c23e6ce
 
 
 
 
 
 
 
d6ea280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
"""Browser-suite plumbing: a static server for the standalone harness, a real Gradio
app subprocess per transport for the parity proof, and the launch profiles the deployed
suite drives the public Space with.

The local suites must be green BEFORE anything deploys, because that is what makes a
deployed failure interpretable: if standalone passes and deployed fails, the problem is
the host, not three.js.

The deployed profiles are declared once here, as fixtures, so a test that needs a fake
microphone or a browser with no WebGPU says so by requesting one rather than by
repeating Chromium's launch flags. They are the same flags
``tests/e2e/test_asr_standalone.py`` proved locally.
"""

from __future__ import annotations

import contextlib
import functools
import http.server
import mimetypes
import os
import socket
import subprocess
import sys
import tempfile
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

# --------------------------------------------------------------------- deployed profiles

# Chromium's fake media stack: the permission prompt auto-accepts, the fake device is
# selected, and --use-file-for-fake-audio-capture (added per launch) feeds a 16-bit PCM
# WAV as the microphone. %noloop plays it once and then silence, so a hold longer than
# the clip captures the clip plus silence rather than the clip twice.
AUDIO_CAPTURE_ARGS = (
    "--autoplay-policy=no-user-gesture-required",
    "--use-fake-ui-for-media-stream",
    "--use-fake-device-for-media-stream",
)
NO_WEBGPU_ARGS = ("--disable-features=WebGPU", "--disable-gpu")

# Chromium 151 keeps navigator.gpu defined with WebGPU disabled by flag - the adapter is
# withheld but the API surface stays. test_asr_wasm_fallback asserts the branch where the
# API is ABSENT, so the property is deleted in the page as well. A bare statement, not an
# arrow function: add_init_script evaluates the string as a script.
HIDE_WEBGPU = "try { delete Navigator.prototype.gpu; } catch (e) {}"

# whisper-base q4 is 135.8 MB on first use. The model lives in the browser's Cache API,
# keyed by page origin, so a stable profile directory is what makes the second deployed
# run a disk read instead of a download. Separate from the local ASR suite's profile:
# the origins differ, so the caches would not be shared anyway.
DEPLOYED_ASR_PROFILE = Path(tempfile.gettempdir()) / "jla-deployed-chromium-profile"

# The Space sleeps after 48 h (gcTimeout 172800); the first navigation of a session may
# pay a cold start. Hugging Face answers 503 while the container is scheduled and built.
COLD_START_TIMEOUT_S = 300
COLD_START_POLL_S = 5
STAGE_ATTACHED_TIMEOUT_MS = 30_000
AVATAR_READY_TIMEOUT_MS = 90_000
FIRST_FRAME_TIMEOUT_MS = 30_000

AVATAR_READY = (
    "() => !!window.Avatar && !!window.Avatar.__debug && window.Avatar.__debug.ready === true"
)
# ready fires before the first frame renders, and the first frame compiles every MToon
# shader (>1.5 s headless). breathValue is written every tick and is 0 only before the
# first one, so it is the rendered-frame signal. Never a fixed sleep.
AVATAR_FIRST_FRAME = """
async () => {
  const d = window.Avatar ? await window.Avatar.getDebug() : null;
  return !!d && d.breathValue !== 0;
}
"""

# Installed before navigation. Subscribes to the facade's event bus the moment
# window.Avatar exists - which is before the VRM finishes mounting, so nothing a turn
# emits can be missed - and records every event with the page clock and, where the
# event carries one, the played AudioBuffer's duration. Also installs a viseme watcher
# that samples currentVisemes at animation-frame rate between the next speech-start
# and the speech-end that follows it, so "the mouth moved during THIS playback" is a
# number rather than a page-lifetime peak.
EVENT_TAP_JS = """
(() => {
  const NAMES = ['turn-start', 'turn', 'speech-start', 'speech-end', 'latency', 'error',
                 'transcript', 'asr-tier', 'listening', 'replay'];
  const VISEMES = ['aa', 'ih', 'ou', 'ee', 'oh'];
  window.__events = [];
  const plain = (d) => {
    try { return d === undefined ? null : JSON.parse(JSON.stringify(d)); }
    catch (e) { return null; }
  };
  const attach = () => {
    const a = window.Avatar;
    if (!a || typeof a.on !== 'function' || a.__eventTap) return false;
    a.__eventTap = true;
    for (const name of NAMES) {
      a.on(name, (d) => window.__events.push({
        event: name,
        t: performance.now(),
        audioDuration: d && typeof d.duration === 'number' ? d.duration : null,
        data: plain(d),
      }));
    }
    return true;
  };
  const timer = setInterval(() => { if (attach()) clearInterval(timer); }, 20);

  window.__watchVisemes = (timeoutMs) => new Promise((resolve) => {
    const max = {}; for (const v of VISEMES) max[v] = 0;
    const count = (name) => window.__events.filter((e) => e.event === name).length;
    const starts = count('speech-start');
    const ends = count('speech-end');
    let frames = 0;
    const t0 = performance.now();
    const step = async () => {
      const started = count('speech-start') > starts;
      if (started && window.Avatar) {
        try {
          const d = await window.Avatar.getDebug();
          for (const v of VISEMES) max[v] = Math.max(max[v], d.currentVisemes[v]);
          frames += 1;
        } catch (e) { /* mid-render; next frame answers */ }
      }
      if ((started && count('speech-end') > ends) || performance.now() - t0 >= timeoutMs) {
        resolve({ max, frames, started, timedOut: performance.now() - t0 >= timeoutMs });
      } else {
        requestAnimationFrame(step);
      }
    };
    requestAnimationFrame(step);
  });
})();
"""


def fake_audio_arg(wav: Path) -> str:
    """The fake-microphone flag. Chromium wants an absolute POSIX-style path."""
    return f"--use-file-for-fake-audio-capture={Path(wav).resolve().as_posix()}%noloop"


@pytest.fixture
def chromium_with_audio(playwright):
    """A browser whose microphone is a WAV file.

    Returns a context-manager factory: ``with chromium_with_audio(wav) as page:``. Each
    call launches its own Chromium, because the capture file is a launch argument and
    %noloop plays it exactly once per capture stream. ``persistent=True`` reuses
    DEPLOYED_ASR_PROFILE so a test that loads the ASR model pays the download once.
    """

    @contextlib.contextmanager
    def launch(wav: Path, *, no_webgpu: bool = False, persistent: bool = False):
        args = [*AUDIO_CAPTURE_ARGS, fake_audio_arg(wav)]
        if no_webgpu:
            args.extend(NO_WEBGPU_ARGS)
        browser = None
        if persistent:
            DEPLOYED_ASR_PROFILE.mkdir(parents=True, exist_ok=True)
            context = playwright.chromium.launch_persistent_context(
                user_data_dir=str(DEPLOYED_ASR_PROFILE), args=args
            )
        else:
            browser = playwright.chromium.launch(args=args)
            context = browser.new_context()
        try:
            if no_webgpu:
                context.add_init_script(HIDE_WEBGPU)
            page = context.new_page()
            page.add_init_script(EVENT_TAP_JS)
            yield page
        finally:
            context.close()
            if browser is not None:
                browser.close()

    return launch


@pytest.fixture
def chromium_no_webgpu(chromium_with_audio):
    """The same, with WebGPU disabled by flag AND navigator.gpu removed from the page."""
    return functools.partial(chromium_with_audio, no_webgpu=True)


@pytest.fixture
def request_counter():
    """``with request_counter(page) as seen:`` - every request URL issued in the window.

    Nothing is excluded. If a replay triggers so much as a favicon fetch, the list says
    so and the assertion can then be scoped deliberately rather than silently.
    """

    @contextlib.contextmanager
    def counting(page):
        seen: list[str] = []

        def record(request):
            seen.append(request.url)

        page.on("request", record)
        try:
            yield seen
        finally:
            page.remove_listener("request", record)

    return counting


@pytest.fixture
def speech_events():
    """Read the events EVENT_TAP_JS recorded: ``[{event, t, audioDuration, data}]``.

    ``install(page)`` is only needed for pages that did not come from
    ``chromium_with_audio``, which installs the tap itself.
    """

    class Events:
        @staticmethod
        def install(page) -> None:
            page.add_init_script(EVENT_TAP_JS)

        @staticmethod
        def read(page) -> list[dict]:
            return page.evaluate("() => window.__events || []")

        @staticmethod
        def clear(page) -> None:
            page.evaluate("() => { window.__events = []; }")

        @staticmethod
        def named(page, name: str) -> list[dict]:
            return [e for e in Events.read(page) if e["event"] == name]

        @staticmethod
        def wait_for(page, name: str, *, timeout_ms: int, at_least: int = 1) -> list[dict]:
            page.wait_for_function(
                "([name, n]) => (window.__events || []).filter((e) => e.event === name)"
                ".length >= n",
                arg=[name, at_least],
                timeout=timeout_ms,
            )
            return Events.named(page, name)

    return Events


@pytest.fixture
def wait_for_avatar_ready():
    """``wait(page, url)``: load the Space and return once the avatar has RENDERED a frame.

    Retries the initial navigation for up to COLD_START_TIMEOUT_S, because a sleeping
    Space serves 503 while it is scheduled and built. Waits for ``ready`` and then for
    the first rendered frame, since ready fires before the shaders compile.
    """

    def wait(page, url: str, *, timeout_ms: int = AVATAR_READY_TIMEOUT_MS) -> dict:
        t0 = time.monotonic()
        deadline = t0 + COLD_START_TIMEOUT_S
        last: object = None
        while True:
            try:
                response = page.goto(url, timeout=STAGE_ATTACHED_TIMEOUT_MS + 30_000)
                if response is not None and response.ok:
                    break
                last = response.status if response is not None else "no response"
            except Exception as err:  # noqa: BLE001 - the retry IS the cold-start handling
                last = err
            if time.monotonic() > deadline:
                raise AssertionError(
                    f"{url} never answered 2xx within {COLD_START_TIMEOUT_S}s; last: {last!r}"
                )
            time.sleep(COLD_START_POLL_S)
        page.wait_for_selector("#vrm-stage", state="attached", timeout=STAGE_ATTACHED_TIMEOUT_MS)
        page.wait_for_function(AVATAR_READY, timeout=timeout_ms)
        ready_s = time.monotonic() - t0
        page.wait_for_function(AVATAR_FIRST_FRAME, timeout=FIRST_FRAME_TIMEOUT_MS)
        rendered_s = time.monotonic() - t0
        return {"ready_seconds": ready_s, "first_frame_seconds": rendered_s}

    return wait


# ------------------------------------------------------------------------ local plumbing


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.

    The vendored runtime is ``avatar/vendor/*.mjs`` and module scripts are MIME-checked
    strictly; SimpleHTTPRequestHandler defers to ``mimetypes``, which on Windows reads
    ``.mjs`` as text/plain from the registry. Registered here exactly as the app does in
    ``avatar_component.py``, so the standalone layer serves what the app serves.
    """
    mimetypes.add_type("text/javascript", ".mjs")
    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()