Spaces:
Running on Zero
Running on Zero
File size: 35,486 Bytes
f2435d2 d6ea280 f2435d2 d6ea280 f2435d2 d6ea280 c23e6ce d6ea280 f2435d2 d6ea280 f2435d2 73b5215 f2435d2 73b5215 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 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 | """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"
# ------------------------------------------------------------ strict autoplay (plan 01-11)
#
# Every profile above, and the session-wide one below, runs Chromium with
# --autoplay-policy=no-user-gesture-required. That flag is what let the phone bug ship: an
# AudioContext resumed seconds after the tap looked fine in every test and was refused on a
# real gesture-gated browser. The profile here launches WITHOUT it, under Chromium's strict
# default, and drives the page so that NOTHING grants user activation before the one
# trusted tap under test. Three facts, all measured on this fleet (Chromium 151) while this
# profile was built, shape it:
#
# 1. Chromium has two strict values. document-user-activation-required (the desktop
# default) gates a top-level page: an ungestured context is created 'suspended', an
# ungestured resume() is refused with a console warning and its promise stays PENDING.
# Activation is STICKY: after any trusted input, a resume() issued at any later time is
# allowed. user-gesture-required gates Web Audio only inside cross-origin frames; at the
# top level it leaves the context constructed 'running', so it cannot serve as a strict
# profile here. It is what the huggingface.co embed applies to the Space, and a note
# for a future cross-origin-embed row.
# 2. Playwright's page.evaluate / wait_for_function / wait_for_selector run through CDP
# with userGesture:true and GRANT activation - navigator.userActivation.hasBeenActive
# flips true - after which the sticky policy lets a late resume() through. A strict test
# therefore must not touch the page with those before the tap: reads go through a raw
# CDP session with userGesture:false, and the tap is raw mouse input at coordinates read
# the same way. After the tap activation exists in real life too, so Playwright's own
# helpers are fine again.
# 3. A trusted tap grants activation in every launch mode (headless shell, new headless,
# headed), and the resume() -> 'running' transition then takes ~0.6-0.7 s here - the
# headless audio sink starting - so 'running' is waited for, never read once.
#
# iOS Safari is stricter than any Chromium policy: the resume() call must be on the
# gesture's own call stack. No flag models that; GESTURE_TAP_JS does, by recording whether
# each resume() was issued while a trusted input event was being dispatched.
STRICT_AUTOPLAY_POLICY = "document-user-activation-required"
CDP_POLL_S = 0.1
# A trusted tap's resume() reaches 'running' once the audio sink starts (~0.7 s measured).
UNLOCK_RUNNING_TIMEOUT_MS = 10_000
# The load-bearing bound is relative: 'running' must be observed BEFORE the server answers
# ('turn'), which is seconds later on the turn path. This absolute ceiling is only a sanity
# check - measured tap-to-running is 0.7 s on an idle fleet and up to ~3.5 s with two
# Gradio apps synthesising under SwiftShader rendering, so it is set well above both.
UNLOCK_BEFORE_RESPONSE_MS = 10_000
# A gesture relayed into the same-origin stage frame by postMessage lands a task later.
RELAYED_UNLOCK_MAX_MS = 1_000
# Installed before navigation, in every frame. Wraps AudioContext.prototype.resume so each
# call records whether it was issued inside the dispatch of a trusted input event, and
# whether it was on that dispatch's CALL STACK - the distinction iOS enforces.
#
# "Inside the dispatch" (inDispatch) is a capture listener on document (runs before any
# handler on the target) and a bubble listener on window (runs after every handler), with
# a macrotask fallback in case a handler stops propagation. That alone is not enough: an
# `await` continuation queued by a handler runs as a microtask before the bubble listener,
# still "inside" the dispatch but no longer on the gesture's stack. So `sync` is measured
# exactly: addEventListener is wrapped for the gesture event types and every page listener
# runs inside a depth counter; a resume() with the counter above zero was called
# synchronously from a trusted-input listener, and a continuation - which runs after the
# listener returned - sees zero. Records carry both the page clock and the wall clock,
# because a child frame has its own performance.now().
GESTURE_TAP_JS = """
(() => {
const GESTURES = ['pointerdown', 'mousedown', 'touchstart', 'keydown', 'click'];
const rec = { inDispatch: false, syncDepth: 0, resumeCalls: [], dispatches: [] };
window.__gesture = rec;
const enter = (e) => {
rec.inDispatch = true;
rec.dispatches.push({ type: e.type, trusted: e.isTrusted, t: performance.now(),
wall: Date.now() });
setTimeout(() => { rec.inDispatch = false; }, 0);
};
const leave = () => { rec.inDispatch = false; };
for (const name of GESTURES) {
document.addEventListener(name, enter, true);
window.addEventListener(name, leave, false);
}
const wrappers = new WeakMap();
const proto = EventTarget.prototype;
const add = proto.addEventListener;
const remove = proto.removeEventListener;
proto.addEventListener = function addTapped(type, listener, ...rest) {
if (!GESTURES.includes(type) || typeof listener !== 'function') {
return add.call(this, type, listener, ...rest);
}
let wrapped = wrappers.get(listener);
if (!wrapped) {
wrapped = function gestureListener(event) {
if (!event.isTrusted) return listener.call(this, event);
rec.syncDepth += 1;
try {
return listener.call(this, event);
} finally {
rec.syncDepth -= 1;
}
};
wrappers.set(listener, wrapped);
}
return add.call(this, type, wrapped, ...rest);
};
proto.removeEventListener = function removeTapped(type, listener, ...rest) {
const wrapped = typeof listener === 'function' ? wrappers.get(listener) : null;
return remove.call(this, type, wrapped || listener, ...rest);
};
const Ctor = window.AudioContext || window.webkitAudioContext;
if (Ctor && Ctor.prototype && typeof Ctor.prototype.resume === 'function') {
const original = Ctor.prototype.resume;
Ctor.prototype.resume = function resumeTapped(...args) {
const stack = String(new Error().stack || '').split('\\n').slice(1, 6);
rec.resumeCalls.push({
t: performance.now(), wall: Date.now(), inDispatch: rec.inDispatch,
sync: rec.syncDepth > 0, stateBefore: this.state,
stack: stack.map((line) => line.trim()).join(' | '),
});
return original.apply(this, args);
};
}
})();
"""
# Armed on a page whose avatar is ready, BEFORE the gesture, through the CDP session so it
# grants nothing. Timestamps the trusted tap on `selector` (capture phase on document, so
# before the host's handler runs), samples audioState at animation-frame rate, and records
# the audioState the facade reports at each turn event. getDebug() resolves to the live
# merged object, so every scalar is copied out the moment the promise settles.
UNLOCK_PROBE_JS = """
(selector) => {
const rec = { selector, clickAt: null, clickWall: null, runningAt: null, done: false,
events: [], samples: [] };
window.__unlock = rec;
const plain = (d) => {
try { return d === undefined ? null : JSON.parse(JSON.stringify(d)); }
catch (e) { return null; }
};
const stamp = (e) => {
if (rec.clickAt !== null) return;
const target = e.target && e.target.closest ? e.target.closest(selector) : null;
if (target) { rec.clickAt = performance.now(); rec.clickWall = Date.now(); }
};
document.addEventListener('pointerdown', stamp, true);
document.addEventListener('click', stamp, true);
for (const name of ['turn-start', 'turn', 'speech-start', 'speech-end', 'error']) {
window.Avatar.on(name, (d) => {
const t = performance.now();
window.Avatar.getDebug().then((dbg) => rec.events.push({
name, t, audioState: dbg.audioState, readAt: performance.now(), data: plain(d),
}));
});
}
const step = async () => {
if (rec.done) return;
const t = performance.now();
try {
const dbg = await window.Avatar.getDebug();
rec.samples.push({ t, audioState: dbg.audioState });
if (dbg.audioState === 'running' && rec.runningAt === null) rec.runningAt = t;
} catch (e) { /* mid-render; the next frame answers */ }
requestAnimationFrame(step);
};
requestAnimationFrame(step);
return true;
}
"""
USER_ACTIVATION_JS = (
"navigator.userActivation ? { isActive: navigator.userActivation.isActive, "
"hasBeenActive: navigator.userActivation.hasBeenActive } : null"
)
AVATAR_READY_EXPR = (
"!!window.Avatar && !!window.Avatar.__debug && window.Avatar.__debug.ready === true"
)
AVATAR_FIRST_FRAME_EXPR = (
"window.Avatar ? window.Avatar.getDebug().then((d) => !!d && d.breathValue !== 0) : false"
)
AUDIO_STATE_EXPR = (
"window.Avatar ? window.Avatar.getDebug().then((d) => "
"d.audioState === undefined ? null : d.audioState) : null"
)
def _goto_cold_start_tolerant(page, url: str) -> float:
"""Navigate, retrying while a sleeping Space answers 503. Returns the start time."""
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:
return t0
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)
class StrictPage:
"""A page under the strict autoplay policy plus a raw CDP session, so a test can read
it and tap it without any Playwright helper granting user activation first."""
def __init__(self, page, cdp) -> None:
self.page = page
self.cdp = cdp
def eval(self, js: str):
"""Evaluate WITHOUT granting activation (Runtime.evaluate, userGesture:false).
Accepts an expression, or a zero-argument arrow function which is invoked, so the
suite's existing ``() => ...`` predicates can be reused. Promises are awaited and
the value must be JSON-serialisable.
"""
stripped = js.strip()
expression = f"({stripped})()" if stripped.startswith(("() =>", "async () =>")) else js
result = self.cdp.send(
"Runtime.evaluate",
{
"expression": expression,
"returnByValue": True,
"awaitPromise": True,
"userGesture": False,
},
)
if "exceptionDetails" in result:
detail = result["exceptionDetails"]
text = detail.get("exception", {}).get("description") or detail.get("text")
raise RuntimeError(f"page threw during CDP evaluate: {text}")
return result["result"].get("value")
def wait(self, js: str, *, timeout_ms: int, what: str = ""):
"""Poll ``eval(js)`` until truthy. A page that throws mid-load counts as falsy."""
deadline = time.monotonic() + timeout_ms / 1000
while True:
try:
value = self.eval(js)
except RuntimeError:
value = None
if value:
return value
if time.monotonic() > deadline:
raise AssertionError(f"timed out after {timeout_ms} ms waiting for {what or js}")
time.sleep(CDP_POLL_S)
def user_activation(self) -> dict | None:
return self.eval(USER_ACTIVATION_JS)
def real_click(self, selector: str) -> dict:
"""TRUSTED input, which is the whole point of the strict-policy tests.
The element's centre is read through the CDP session and the tap is
``page.mouse.click`` - raw Input.dispatchMouseEvent, which Chromium treats as a
real pointer and grants user activation for. Not ``page.click``: its actionability
checks evaluate in the page with userGesture:true before the pointer moves, which
would activate the document a moment early. Never ``page.evaluate('el.click()')``:
that dispatches a synthetic event with ``isTrusted === false`` and grants NO
activation, so a strict policy would refuse the resume and the test would fail for
the wrong reason.
"""
rect = self.eval(
f"(() => {{ const el = document.querySelector({selector!r}); if (!el) return null;"
" const r = el.getBoundingClientRect();"
" return { x: r.x + r.width / 2, y: r.y + r.height / 2, w: r.width, h: r.height }; })()"
)
assert rect and rect["w"] > 0 and rect["h"] > 0, f"{selector} has no box to tap: {rect}"
self.page.mouse.click(rect["x"], rect["y"])
return rect
def open_ready(self, url: str, *, timeout_ms: int = AVATAR_READY_TIMEOUT_MS) -> dict:
"""The gesture-free twin of ``wait_for_avatar_ready``: load, wait for ready and
then for the first RENDERED frame, reading only through the CDP session."""
t0 = _goto_cold_start_tolerant(self.page, url)
self.wait(
"!!document.querySelector('#vrm-stage')",
timeout_ms=STAGE_ATTACHED_TIMEOUT_MS,
what="#vrm-stage attached",
)
self.wait(AVATAR_READY_EXPR, timeout_ms=timeout_ms, what="window.Avatar ready")
ready_s = time.monotonic() - t0
self.wait(AVATAR_FIRST_FRAME_EXPR, timeout_ms=FIRST_FRAME_TIMEOUT_MS, what="first frame")
return {"ready_seconds": ready_s, "first_frame_seconds": time.monotonic() - t0}
@pytest.fixture
def chromium_strict_autoplay(playwright):
"""``with chromium_strict_autoplay() as strict:`` - a fresh Chromium under
STRICT_AUTOPLAY_POLICY, deliberately not built from ``browser_type_launch_args`` (which
carries the relaxed flag the rest of the suite depends on), with the event tap and the
gesture tap installed in every frame before navigation, wrapped as a StrictPage."""
@contextlib.contextmanager
def launch():
browser = playwright.chromium.launch(args=[f"--autoplay-policy={STRICT_AUTOPLAY_POLICY}"])
try:
context = browser.new_context()
page = context.new_page()
page.add_init_script(EVENT_TAP_JS)
page.add_init_script(GESTURE_TAP_JS)
cdp = context.new_cdp_session(page)
yield StrictPage(page, cdp)
finally:
browser.close()
return launch
@pytest.fixture
def real_click():
"""``real_click(strict, selector)`` - see StrictPage.real_click for why it is raw input."""
def click(strict: StrictPage, selector: str) -> dict:
return strict.real_click(selector)
return click
@pytest.fixture
def audio_unlock_probe():
"""The proof that one tap unlocks the audio clock, packaged so the both-transports and
deployed layers assert exactly the same things.
``precondition(strict)`` asserts the ungestured page is really gated; ``arm`` installs
UNLOCK_PROBE_JS through CDP; after the tap, ``wait`` blocks until speech-end (or an
error) and returns the record plus every frame's gesture tap; ``assert_unlocked`` is
the shared check and returns the numbers it derived, for the record.
"""
class Probe:
@staticmethod
def precondition(strict: StrictPage) -> dict:
"""Load-bearing: without this a relaxed profile, or a document something has
already activated, would let the rest pass vacuously."""
state = strict.eval(AUDIO_STATE_EXPR)
activation = strict.user_activation()
assert activation is not None, "navigator.userActivation is missing"
assert activation["hasBeenActive"] is False, (
f"the document was activated before the tap ({activation}); something "
"evaluated with a user gesture - reads before the tap must go through CDP"
)
assert state == "suspended", (
f"audioState reads {state!r} before any gesture. None: the page does not "
"publish audioState (a deployed build before plan 01-11 - push first). "
"'running': the profile is not strict and this test would pass vacuously"
)
return {"audioState": state, "activation": activation}
@staticmethod
def arm(strict: StrictPage, selector: str) -> None:
assert strict.eval(f"({UNLOCK_PROBE_JS})({selector!r})") is True
@staticmethod
def wait(strict: StrictPage, *, end_timeout_ms: int) -> dict:
page = strict.page
page.wait_for_function(
"() => window.__unlock.events.some((e) => e.name === 'speech-end' || "
"e.name === 'error')",
timeout=end_timeout_ms,
)
record = page.evaluate("() => { window.__unlock.done = true; return window.__unlock; }")
frames = {}
for frame in page.frames:
try:
frames[frame.url] = frame.evaluate("() => window.__gesture")
except Exception: # noqa: BLE001 - a detached or opaque frame has no tap
continue
return {"record": record, "gesture": frames}
@staticmethod
def assert_unlocked(report: dict, *, relayed: bool = False) -> dict:
"""relayed=True is the iframe transport: the resume() happens inside the stage
frame on receipt of a postMessage, so it is asserted by wall-clock proximity
to the tap rather than by being on the tap's own call stack."""
rec = report["record"]
names = [e["name"] for e in rec["events"]]
errors = [e for e in rec["events"] if e["name"] == "error"]
assert errors == [], f"the turn emitted error events: {errors}"
assert "speech-start" in names and "speech-end" in names, names
assert rec["clickAt"] is not None, f"no trusted tap on {rec['selector']} was seen"
turn = next((e for e in rec["events"] if e["name"] == "turn"), None)
start = next(e for e in rec["events"] if e["name"] == "speech-start")
assert rec["runningAt"] is not None, (
f"audioState never read 'running'; samples {rec['samples'][:5]}..."
)
running_after_click_ms = rec["runningAt"] - rec["clickAt"]
assert running_after_click_ms >= -1, "running before the tap; the profile is not strict"
if turn is not None:
assert rec["runningAt"] < turn["t"], (
f"audioState became 'running' {rec['runningAt'] - turn['t']:.0f} ms AFTER "
"the server answered - the unlock did not happen in the gesture"
)
assert running_after_click_ms <= UNLOCK_BEFORE_RESPONSE_MS, running_after_click_ms
assert start["audioState"] == "running", (
f"audioState was {start['audioState']!r} at speech-start"
)
# The property iOS enforces: the resume() CALL was issued during the trusted
# input's dispatch AND on its call stack, not in a continuation queued by it.
# Inline: the page's own context. Iframe: relayed into the stage frame.
calls = [c for g in report["gesture"].values() for c in g["resumeCalls"]]
in_dispatch = [c for c in calls if c["inDispatch"] and c["sync"]]
if relayed:
near = [
c
for c in calls
if rec["clickWall"] - 1 <= c["wall"] <= rec["clickWall"] + RELAYED_UNLOCK_MAX_MS
]
assert near, (
f"no AudioContext.resume() within {RELAYED_UNLOCK_MAX_MS} ms of the tap in "
f"any frame; calls {calls}"
)
else:
assert in_dispatch, (
"no AudioContext.resume() was issued INSIDE the trusted input's dispatch; "
f"calls seen: {calls}"
)
return {
"running_after_click_ms": round(running_after_click_ms),
"response_after_click_ms": round(turn["t"] - rec["clickAt"]) if turn else None,
"speech_start_after_click_ms": round(start["t"] - rec["clickAt"]),
"resume_calls": len(calls),
"resume_calls_in_dispatch": len(in_dispatch),
"audio_duration": next(
(
e["data"]["duration"]
for e in rec["events"]
if e["name"] == "speech-end" and e["data"]
),
None,
),
}
return Probe
@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 = _goto_cold_start_tolerant(page, url)
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()
|