Spaces:
Running on Zero
test(01-03): enforce the transport seam statically and in a real browser
Browse files- tests/test_transport_seam.py: 36 static cases; no stage-core or shared module may
name the host framework, no transport may assign window.Avatar or implement turn
behaviour, and AVATAR_SURFACE is parsed from facade.js rather than duplicated
- host-token checks are case-insensitive, so a capitalised mention cannot slip past
- tests/e2e/test_stage_standalone.py: 4 tests against avatar/stage.html on a static
server - one three.mjs instance, idle life, demo lip-sync, no duplicate-instance
console warning. Sampling runs in-page at frame rate: a 250 ms poll would miss a
120 ms blink on most runs and be flaky by construction
- tests/e2e/test_facade_parity.py: boots the real app twice, once per transport, and
compares the live method sets, debug keys and deferred-method errors
- stage.html: mirror the event stream onto window.__stageEvents so the standalone
harness can assert on speech-end with no driver on the other side
- avatar_component.py: js_on_load is compiled into a NON-async Function by gradio
6.22.0, so the documented top-level await is a SyntaxError and the component never
booted; wrap in an async IIFE with its own catch
- avatar_component.py: custom props are **kwargs on gr.HTML, not a props= dict; the
dict form made props.vrmUrl undefined and GLTFLoader threw
- avatar/stage.html +10 -1
- src/japanese_avatar/ui/avatar_component.py +23 -10
- tests/e2e/conftest.py +125 -0
- tests/e2e/test_facade_parity.py +106 -0
- tests/e2e/test_stage_standalone.py +132 -0
- tests/test_transport_seam.py +139 -0
|
@@ -64,7 +64,16 @@
|
|
| 64 |
|
| 65 |
const host = window.parent !== window ? window.parent : null;
|
| 66 |
const post = (msg) => host && host.postMessage(msg, '*');
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
const canvas = document.getElementById('vrm-canvas');
|
| 70 |
let audioCtx = null;
|
|
|
|
| 64 |
|
| 65 |
const host = window.parent !== window ? window.parent : null;
|
| 66 |
const post = (msg) => host && host.postMessage(msg, '*');
|
| 67 |
+
|
| 68 |
+
// Opened standalone there is no parent to post to, so the event stream would
|
| 69 |
+
// vanish. Mirroring it onto window.__stageEvents is the harness counterpart of
|
| 70 |
+
// window.__stageDebug: it lets a browser test assert on speech-start /
|
| 71 |
+
// speech-end / error without a driver on the other side of the boundary.
|
| 72 |
+
window.__stageEvents = [];
|
| 73 |
+
const emit = (event, data) => {
|
| 74 |
+
window.__stageEvents.push({ event, data, at: performance.now() });
|
| 75 |
+
post({ type: 'avatar:event', event, data });
|
| 76 |
+
};
|
| 77 |
|
| 78 |
const canvas = document.getElementById('vrm-canvas');
|
| 79 |
let audioCtx = null;
|
|
@@ -15,17 +15,25 @@ VRM_URL = "/gradio_api/file=avatar/assets/tutor.vrm"
|
|
| 15 |
|
| 16 |
# No `head=`: the modules load via dynamic import() inside js_on_load, so there is no
|
| 17 |
# ordering constraint and no import map to be injected too late.
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
"""
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
await m.boot(element, props, trigger, server);
|
| 27 |
-
watch('value', () => m.onDirective(props.value));
|
| 28 |
-
"""
|
| 29 |
|
| 30 |
|
| 31 |
class VrmStage(gr.HTML):
|
|
@@ -45,7 +53,12 @@ class VrmStage(gr.HTML):
|
|
| 45 |
".vrm-stage iframe{width:100%;height:100%;border:0;display:block}"
|
| 46 |
),
|
| 47 |
js_on_load=_IFRAME_JS if AVATAR_TRANSPORT == "iframe" else _INLINE_JS,
|
| 48 |
-
props
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
container=False,
|
| 50 |
padding=False,
|
| 51 |
min_height=480,
|
|
|
|
| 15 |
|
| 16 |
# No `head=`: the modules load via dynamic import() inside js_on_load, so there is no
|
| 17 |
# ordering constraint and no import map to be injected too late.
|
| 18 |
+
#
|
| 19 |
+
# The async IIFE is NOT decoration. Gradio 6.22.0 compiles this string with
|
| 20 |
+
# `Function('element','trigger','props','server','upload','watch', js_on_load)` - a
|
| 21 |
+
# plain, non-async function - so a top-level `await` here is a SyntaxError and the
|
| 22 |
+
# component silently never boots. The published docs snippet uses top-level await and
|
| 23 |
+
# does not work on this version. Verified against gradio/templates/frontend/assets.
|
| 24 |
+
#
|
| 25 |
+
# The IIFE also escapes Gradio's own try/catch, so it carries its own .catch: a boot
|
| 26 |
+
# failure must reach the console, because that console line is plan 01-05's verdict.
|
| 27 |
+
_BOOT_JS = """
|
| 28 |
+
(async () => {{
|
| 29 |
+
const m = await import('/gradio_api/file=avatar/{module}');
|
| 30 |
+
await m.boot(element, props, trigger, server);
|
| 31 |
+
watch('value', () => m.onDirective(props.value));
|
| 32 |
+
}})().catch((err) => console.error('avatar boot failed:', err));
|
| 33 |
"""
|
| 34 |
|
| 35 |
+
_INLINE_JS = _BOOT_JS.format(module="avatar.js")
|
| 36 |
+
_IFRAME_JS = _BOOT_JS.format(module="avatar-iframe.js")
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
class VrmStage(gr.HTML):
|
|
|
|
| 53 |
".vrm-stage iframe{width:100%;height:100%;border:0;display:block}"
|
| 54 |
),
|
| 55 |
js_on_load=_IFRAME_JS if AVATAR_TRANSPORT == "iframe" else _INLINE_JS,
|
| 56 |
+
# Custom props are **kwargs on gr.HTML, NOT a props= dict. Passing
|
| 57 |
+
# props={"vrmUrl": ...} creates a single prop literally named "props"
|
| 58 |
+
# and js_on_load then reads props.vrmUrl as undefined - the VRM URL
|
| 59 |
+
# reaches GLTFLoader as undefined and the avatar never mounts.
|
| 60 |
+
vrmUrl=VRM_URL,
|
| 61 |
+
transport=AVATAR_TRANSPORT,
|
| 62 |
container=False,
|
| 63 |
padding=False,
|
| 64 |
min_height=480,
|
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Browser-suite plumbing: a static server for the standalone harness and a real
|
| 2 |
+
Gradio app subprocess per transport for the parity proof.
|
| 3 |
+
|
| 4 |
+
Both suites run entirely locally. They must be green BEFORE plan 01-05 deploys
|
| 5 |
+
anything, because that is what makes 01-05's verdict interpretable: if standalone
|
| 6 |
+
passes and deployed fails, the problem is the host, not three.js.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import functools
|
| 12 |
+
import http.server
|
| 13 |
+
import os
|
| 14 |
+
import socket
|
| 15 |
+
import subprocess
|
| 16 |
+
import sys
|
| 17 |
+
import threading
|
| 18 |
+
import time
|
| 19 |
+
import urllib.error
|
| 20 |
+
import urllib.request
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import pytest
|
| 24 |
+
|
| 25 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 26 |
+
APP_BOOT_TIMEOUT_S = 180
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _free_port() -> int:
|
| 30 |
+
with socket.socket() as s:
|
| 31 |
+
s.bind(("127.0.0.1", 0))
|
| 32 |
+
return s.getsockname()[1]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.fixture(scope="session")
|
| 36 |
+
def browser_type_launch_args(browser_type_launch_args):
|
| 37 |
+
"""Chromium's autoplay policy leaves an ungestured AudioContext suspended forever.
|
| 38 |
+
|
| 39 |
+
A suspended context never advances currentTime, so the viseme player would never
|
| 40 |
+
tick and 'speech-end' would never fire - the test would be measuring the autoplay
|
| 41 |
+
policy rather than the lip-sync. The flag is the standard way to opt out.
|
| 42 |
+
"""
|
| 43 |
+
return {
|
| 44 |
+
**browser_type_launch_args,
|
| 45 |
+
"args": [
|
| 46 |
+
*browser_type_launch_args.get("args", []),
|
| 47 |
+
"--autoplay-policy=no-user-gesture-required",
|
| 48 |
+
],
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@pytest.fixture(scope="session")
|
| 53 |
+
def static_server() -> str:
|
| 54 |
+
"""Serve the repo root so /avatar/stage.html and /avatar/assets/tutor.vrm resolve."""
|
| 55 |
+
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT))
|
| 56 |
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", _free_port()), handler)
|
| 57 |
+
server.daemon_threads = True
|
| 58 |
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
| 59 |
+
try:
|
| 60 |
+
yield f"http://127.0.0.1:{server.server_port}"
|
| 61 |
+
finally:
|
| 62 |
+
server.shutdown()
|
| 63 |
+
server.server_close()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _wait_for_http(url: str, proc: subprocess.Popen, timeout_s: int) -> None:
|
| 67 |
+
deadline = time.time() + timeout_s
|
| 68 |
+
while time.time() < deadline:
|
| 69 |
+
if proc.poll() is not None:
|
| 70 |
+
out = proc.stdout.read() if proc.stdout else ""
|
| 71 |
+
raise RuntimeError(f"app.py exited with {proc.returncode}:\n{out}")
|
| 72 |
+
try:
|
| 73 |
+
with urllib.request.urlopen(url, timeout=2) as response:
|
| 74 |
+
if response.status == 200:
|
| 75 |
+
return
|
| 76 |
+
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError):
|
| 77 |
+
time.sleep(0.5)
|
| 78 |
+
raise RuntimeError(f"app.py did not answer {url} within {timeout_s}s")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@pytest.fixture(scope="session")
|
| 82 |
+
def gradio_apps():
|
| 83 |
+
"""Start app.py once per AVATAR_TRANSPORT value and return its base URL.
|
| 84 |
+
|
| 85 |
+
One env var is the entire difference between the two deployments, which is the
|
| 86 |
+
property the parity suite exists to prove.
|
| 87 |
+
"""
|
| 88 |
+
started: dict[str, tuple[str, subprocess.Popen]] = {}
|
| 89 |
+
|
| 90 |
+
def start(transport: str) -> str:
|
| 91 |
+
if transport not in started:
|
| 92 |
+
port = _free_port()
|
| 93 |
+
env = {
|
| 94 |
+
**os.environ,
|
| 95 |
+
"AVATAR_TRANSPORT": transport,
|
| 96 |
+
"GRADIO_SERVER_NAME": "127.0.0.1",
|
| 97 |
+
"GRADIO_SERVER_PORT": str(port),
|
| 98 |
+
"GRADIO_ANALYTICS_ENABLED": "False",
|
| 99 |
+
}
|
| 100 |
+
proc = subprocess.Popen( # noqa: S603
|
| 101 |
+
[sys.executable, "app.py"],
|
| 102 |
+
cwd=str(REPO_ROOT),
|
| 103 |
+
env=env,
|
| 104 |
+
stdout=subprocess.PIPE,
|
| 105 |
+
stderr=subprocess.STDOUT,
|
| 106 |
+
text=True,
|
| 107 |
+
)
|
| 108 |
+
url = f"http://127.0.0.1:{port}/"
|
| 109 |
+
try:
|
| 110 |
+
_wait_for_http(url, proc, APP_BOOT_TIMEOUT_S)
|
| 111 |
+
except Exception:
|
| 112 |
+
proc.kill()
|
| 113 |
+
raise
|
| 114 |
+
started[transport] = (url, proc)
|
| 115 |
+
return started[transport][0]
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
yield start
|
| 119 |
+
finally:
|
| 120 |
+
for _url, proc in started.values():
|
| 121 |
+
proc.terminate()
|
| 122 |
+
try:
|
| 123 |
+
proc.wait(timeout=15)
|
| 124 |
+
except subprocess.TimeoutExpired:
|
| 125 |
+
proc.kill()
|
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mechanical proof that the two transports expose the same object.
|
| 2 |
+
|
| 3 |
+
The static tests in tests/test_transport_seam.py can only prove that no transport
|
| 4 |
+
*writes* window.Avatar. These boot the real Gradio app twice - once per
|
| 5 |
+
AVATAR_TRANSPORT value - and compare the LIVE objects, which is the only way to catch
|
| 6 |
+
a method that resolves in one transport and silently does not in the other.
|
| 7 |
+
|
| 8 |
+
Marked slow but NOT deployed: they run entirely locally and must be green before
|
| 9 |
+
plan 01-05 deploys anything.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from tests.test_transport_seam import avatar_surface
|
| 17 |
+
|
| 18 |
+
pytestmark = pytest.mark.slow
|
| 19 |
+
|
| 20 |
+
TRANSPORTS = ("inline", "iframe")
|
| 21 |
+
AVATAR_READY = "() => !!window.Avatar && !!window.Avatar.__debug && window.Avatar.__debug.ready"
|
| 22 |
+
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 {
|
| 32 |
+
await window.Avatar[name]('x');
|
| 33 |
+
return { threw: false, message: '' };
|
| 34 |
+
} catch (err) {
|
| 35 |
+
return { threw: true, message: String((err && err.message) || err) };
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@pytest.fixture(scope="session")
|
| 42 |
+
def live_avatars(browser, gradio_apps):
|
| 43 |
+
"""Boot each transport once and capture everything the three tests compare."""
|
| 44 |
+
captured = {}
|
| 45 |
+
for transport in TRANSPORTS:
|
| 46 |
+
url = gradio_apps(transport)
|
| 47 |
+
page = browser.new_page()
|
| 48 |
+
try:
|
| 49 |
+
page.goto(url)
|
| 50 |
+
page.wait_for_function(AVATAR_READY, timeout=BOOT_TIMEOUT_MS)
|
| 51 |
+
captured[transport] = {
|
| 52 |
+
"surface": page.evaluate(
|
| 53 |
+
"() => Object.keys(window.Avatar).filter(k => k !== '__debug').sort()"
|
| 54 |
+
),
|
| 55 |
+
"debug_keys": page.evaluate(
|
| 56 |
+
"async () => Object.keys(await window.Avatar.getDebug()).sort()"
|
| 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()
|
| 63 |
+
return captured
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_transports_expose_identical_surfaces(live_avatars):
|
| 67 |
+
inline = live_avatars["inline"]["surface"]
|
| 68 |
+
iframe = live_avatars["iframe"]["surface"]
|
| 69 |
+
expected = sorted(avatar_surface())
|
| 70 |
+
|
| 71 |
+
assert live_avatars["inline"]["reported_transport"] == "inline"
|
| 72 |
+
assert live_avatars["iframe"]["reported_transport"] == "iframe"
|
| 73 |
+
assert inline == iframe, (
|
| 74 |
+
"the transports have drifted; symmetric difference "
|
| 75 |
+
f"(inline ^ iframe) = {sorted(set(inline) ^ set(iframe))}"
|
| 76 |
+
)
|
| 77 |
+
assert inline == expected, (
|
| 78 |
+
"the live object does not match AVATAR_SURFACE parsed from avatar/facade.js; "
|
| 79 |
+
f"symmetric difference = {sorted(set(inline) ^ set(expected))}"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_transports_expose_identical_debug_keys(live_avatars):
|
| 84 |
+
inline = live_avatars["inline"]["debug_keys"]
|
| 85 |
+
iframe = live_avatars["iframe"]["debug_keys"]
|
| 86 |
+
assert inline == iframe, (
|
| 87 |
+
"__debug has drifted between transports; symmetric difference "
|
| 88 |
+
f"(inline ^ iframe) = {sorted(set(inline) ^ set(iframe))}"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_deferred_methods_fail_loudly_not_silently(live_avatars):
|
| 93 |
+
"""The stubs are placeholders, not accidental no-ops.
|
| 94 |
+
|
| 95 |
+
Plan 01-08 deletes this test when it implements the methods for real.
|
| 96 |
+
"""
|
| 97 |
+
for transport in TRANSPORTS:
|
| 98 |
+
for name, outcome in live_avatars[transport]["deferred"].items():
|
| 99 |
+
assert outcome["threw"], (
|
| 100 |
+
f"{transport}: Avatar.{name}() returned instead of throwing; a silent "
|
| 101 |
+
"no-op is exactly what the deferred stubs exist to prevent"
|
| 102 |
+
)
|
| 103 |
+
assert DEFERRED_PLAN in outcome["message"], (
|
| 104 |
+
f"{transport}: Avatar.{name}() threw {outcome['message']!r}, which does "
|
| 105 |
+
f"not name plan {DEFERRED_PLAN}"
|
| 106 |
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The stage core, proven in a real browser with no Python and no host framework.
|
| 2 |
+
|
| 3 |
+
avatar/stage.html is opened directly off a static server. If these four tests pass and
|
| 4 |
+
the deployed component later fails, the fault is provably in the host, not in three.js,
|
| 5 |
+
the VRM or the viseme player. That is the whole point of running them before 01-05.
|
| 6 |
+
|
| 7 |
+
Marked slow but NOT deployed: they need no Space and must be green before one exists.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
pytestmark = pytest.mark.slow
|
| 15 |
+
|
| 16 |
+
# The VRM is 10.3 MiB and the module graph another ~1.4 MiB. This is a real download.
|
| 17 |
+
READY_TIMEOUT_MS = 30_000
|
| 18 |
+
STAGE_READY = "() => !!window.__stageDebug && window.__stageDebug.ready === true"
|
| 19 |
+
|
| 20 |
+
# Sampling happens INSIDE the page at animation-frame rate rather than by polling from
|
| 21 |
+
# Python. A blink is a 120 ms ramp on a 1.8-5.8 s schedule, so its peak occupies well
|
| 22 |
+
# under 2% of the wall clock: a 250 ms poll would miss it on most runs and the test
|
| 23 |
+
# would be flaky by construction. Same sampler, ~60x the rate, no flakiness.
|
| 24 |
+
IDLE_SAMPLER = """
|
| 25 |
+
async (ms) => {
|
| 26 |
+
const blink = [], breath = [];
|
| 27 |
+
const t0 = performance.now();
|
| 28 |
+
return await new Promise((resolve) => {
|
| 29 |
+
const step = () => {
|
| 30 |
+
const d = window.__stageDebug;
|
| 31 |
+
if (d) { blink.push(d.blinkValue); breath.push(d.breathValue); }
|
| 32 |
+
if (performance.now() - t0 >= ms) resolve({ blink, breath });
|
| 33 |
+
else requestAnimationFrame(step);
|
| 34 |
+
};
|
| 35 |
+
requestAnimationFrame(step);
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
# Peak-hold per viseme until the utterance ends, then the settled values afterwards.
|
| 41 |
+
VISEME_SAMPLER = """
|
| 42 |
+
async (ms) => {
|
| 43 |
+
const names = ['aa', 'ih', 'ou', 'ee', 'oh'];
|
| 44 |
+
const peak = {}; for (const n of names) peak[n] = 0;
|
| 45 |
+
const t0 = performance.now();
|
| 46 |
+
const ended = () => (window.__stageEvents || []).some((e) => e.event === 'speech-end');
|
| 47 |
+
return await new Promise((resolve) => {
|
| 48 |
+
const step = () => {
|
| 49 |
+
const d = window.__stageDebug;
|
| 50 |
+
if (d && d.currentVisemes) {
|
| 51 |
+
for (const n of names) peak[n] = Math.max(peak[n], d.currentVisemes[n]);
|
| 52 |
+
}
|
| 53 |
+
if ((ended() && d && names.every((n) => d.currentVisemes[n] === 0))
|
| 54 |
+
|| performance.now() - t0 >= ms) {
|
| 55 |
+
resolve({ peak, settled: d ? d.currentVisemes : null, ended: ended() });
|
| 56 |
+
} else {
|
| 57 |
+
requestAnimationFrame(step);
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
requestAnimationFrame(step);
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _open_stage(page, static_server, query: str = ""):
|
| 67 |
+
page.goto(f"{static_server}/avatar/stage.html{query}")
|
| 68 |
+
page.wait_for_function(STAGE_READY, timeout=READY_TIMEOUT_MS)
|
| 69 |
+
return page.evaluate("() => window.__stageDebug")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_stage_boots_with_one_three_instance(page, static_server):
|
| 73 |
+
"""The single structural fact the whole renderer depends on."""
|
| 74 |
+
debug = _open_stage(page, static_server, "?demo=1")
|
| 75 |
+
|
| 76 |
+
assert debug["threeInstanceCount"] == 1, (
|
| 77 |
+
f"expected exactly one three.mjs resource entry, got {debug['threeInstanceCount']}; "
|
| 78 |
+
"the ?deps=three@0.185.1 pin has stopped converging and the VRM is a T-posed glTF"
|
| 79 |
+
)
|
| 80 |
+
assert isinstance(debug["vrmMetaTitle"], str) and debug["vrmMetaTitle"], (
|
| 81 |
+
"vrmMetaTitle is empty; the VRM loaded without its VRMC_vrm.meta block"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_idle_life_values_change(page, static_server):
|
| 86 |
+
"""AVTR-01's substance: the avatar is alive before any audio exists.
|
| 87 |
+
|
| 88 |
+
12 s spans at least one blink on the 1.8-5.8 s schedule and three 4 s breath cycles.
|
| 89 |
+
Never a pixel diff - the canvas is opaque, the numbers are not.
|
| 90 |
+
"""
|
| 91 |
+
_open_stage(page, static_server)
|
| 92 |
+
samples = page.evaluate(IDLE_SAMPLER, 12_000)
|
| 93 |
+
|
| 94 |
+
breath = samples["breath"]
|
| 95 |
+
blink = samples["blink"]
|
| 96 |
+
assert len({round(b, 4) for b in breath}) > 5, (
|
| 97 |
+
f"breathValue took only {len(set(breath))} distinct values over 12 s; it is not breathing"
|
| 98 |
+
)
|
| 99 |
+
assert max(blink) > 0.5, (
|
| 100 |
+
f"blinkValue peaked at {max(blink)} over 12 s; no blink completed its 0 -> 1 -> 0 ramp"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_demo_timeline_drives_visemes(page, static_server):
|
| 105 |
+
"""A hardcoded timeline against a canned WAV, clocked off the AudioContext."""
|
| 106 |
+
_open_stage(page, static_server, "?demo=1")
|
| 107 |
+
result = page.evaluate(VISEME_SAMPLER, 20_000)
|
| 108 |
+
|
| 109 |
+
peak = result["peak"]
|
| 110 |
+
assert result["ended"], f"no speech-end was emitted; peaks were {peak}"
|
| 111 |
+
|
| 112 |
+
opened = [name for name in ("aa", "ih", "oh") if peak[name] > 0.5]
|
| 113 |
+
assert len(opened) >= 3, (
|
| 114 |
+
f"only {opened} exceeded 0.5; こんにちは drives o, i, i and a, so aa/ih/oh "
|
| 115 |
+
f"must all open. Peaks: {peak}"
|
| 116 |
+
)
|
| 117 |
+
assert all(value == 0 for value in result["settled"].values()), (
|
| 118 |
+
f"the mouth did not shut after speech-end: {result['settled']}"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_console_has_no_multiple_three_warning(page, static_server):
|
| 123 |
+
"""The console message that means the ?deps= pin silently stopped working."""
|
| 124 |
+
messages: list[str] = []
|
| 125 |
+
page.on("console", lambda m: messages.append(m.text))
|
| 126 |
+
page.on("pageerror", lambda e: messages.append(str(e)))
|
| 127 |
+
|
| 128 |
+
_open_stage(page, static_server, "?demo=1")
|
| 129 |
+
page.wait_for_timeout(3_000)
|
| 130 |
+
|
| 131 |
+
offenders = [m for m in messages if "Multiple instances of Three.js" in m]
|
| 132 |
+
assert not offenders, f"three.js reported duplicate instances: {offenders}"
|
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The transport seam. If the gr.HTML spike fails, only the stagePort changes.
|
| 2 |
+
|
| 3 |
+
These tests exist so that stays true as the phase progresses. Plans 01-07 and 01-08
|
| 4 |
+
add turn behaviour; these tests fail if it lands in a transport file instead of in
|
| 5 |
+
the shared turn-loop module.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import pytest
|
| 12 |
+
|
| 13 |
+
# Resolved from this file rather than the process cwd: the quick loop is documented as
|
| 14 |
+
# running from the repo root, but a relative Path() would make that a silent
|
| 15 |
+
# precondition of every assertion below.
|
| 16 |
+
AVATAR = Path(__file__).resolve().parent.parent / "avatar"
|
| 17 |
+
CORE = ["vrm-stage.js", "lipsync.js", "audio-queue.js"]
|
| 18 |
+
SHARED = ["facade.js", "turn-loop.js"]
|
| 19 |
+
TRANSPORTS = ["avatar.js", "avatar-iframe.js"]
|
| 20 |
+
GRADIO_TOKENS = ["gradio", "gradio_api", "server.", "trigger("]
|
| 21 |
+
AMPLITUDE_TOKENS = ["AnalyserNode", "getByteFrequencyData", "getFloatTimeDomainData"]
|
| 22 |
+
TURN_SURFACE = ["startListening", "stopListening", "dispatchTurn", "requestSlower"]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def src(name: str) -> str:
|
| 26 |
+
return (AVATAR / name).read_text(encoding="utf-8")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def avatar_surface() -> list[str]:
|
| 30 |
+
"""Parse AVATAR_SURFACE out of facade.js. Never duplicate the list in the test."""
|
| 31 |
+
m = re.search(r"AVATAR_SURFACE\s*=\s*\[(.*?)\]", src("facade.js"), re.S)
|
| 32 |
+
assert m, "AVATAR_SURFACE not found in avatar/facade.js"
|
| 33 |
+
return re.findall(r"'([a-zA-Z]+)'", m.group(1))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.mark.parametrize("name", CORE)
|
| 37 |
+
def test_stage_core_is_gradio_free(name):
|
| 38 |
+
lowered = src(name).lower()
|
| 39 |
+
for tok in GRADIO_TOKENS:
|
| 40 |
+
assert tok.lower() not in lowered, (
|
| 41 |
+
f"{name} references {tok!r}; the stage core must stay transport-agnostic"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@pytest.mark.parametrize("name", CORE)
|
| 46 |
+
def test_no_amplitude_lipsync(name):
|
| 47 |
+
for tok in AMPLITUDE_TOKENS:
|
| 48 |
+
assert tok not in src(name), f"{name} uses {tok!r}; AVTR-02 disqualifies amplitude lip-sync"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@pytest.mark.parametrize("name", SHARED)
|
| 52 |
+
def test_shared_modules_are_gradio_free(name):
|
| 53 |
+
lowered = src(name).lower()
|
| 54 |
+
for tok in GRADIO_TOKENS:
|
| 55 |
+
assert tok.lower() not in lowered, f"{name} must not know about Gradio"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_lipsync_uses_audiocontext_clock():
|
| 59 |
+
s = src("lipsync.js")
|
| 60 |
+
assert "currentTime" in s
|
| 61 |
+
assert "setTimeout" not in s
|
| 62 |
+
assert "requestAnimationFrame" not in s # the stage owns the loop; the player is a tick fn
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_module_urls_are_deps_pinned():
|
| 66 |
+
s = src("vrm-stage.js")
|
| 67 |
+
assert "https://esm.sh/three@0.185.1" in s
|
| 68 |
+
assert "https://esm.sh/@pixiv/three-vrm@3.5.5?deps=three@0.185.1" in s
|
| 69 |
+
assert "importmap" not in s
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_only_facade_assigns_window_avatar():
|
| 73 |
+
"""One facade builder. Two transports cannot drift if only one of them writes the object."""
|
| 74 |
+
for p in AVATAR.glob("*.js"):
|
| 75 |
+
assigns = re.search(r"window\.Avatar\s*=", p.read_text(encoding="utf-8"))
|
| 76 |
+
if p.name == "facade.js":
|
| 77 |
+
assert assigns, "facade.js must be the one that installs window.Avatar"
|
| 78 |
+
else:
|
| 79 |
+
assert not assigns, f"{p.name} assigns window.Avatar; only facade.js may"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@pytest.mark.parametrize("name", TRANSPORTS)
|
| 83 |
+
def test_transports_only_do_plumbing(name):
|
| 84 |
+
"""A transport builds a stagePort and delegates. It never implements turn behaviour."""
|
| 85 |
+
s = src(name)
|
| 86 |
+
assert "installFacade" in s and "createTurnLoop" in s, (
|
| 87 |
+
f"{name} must use the shared facade/turn loop"
|
| 88 |
+
)
|
| 89 |
+
for member in TURN_SURFACE:
|
| 90 |
+
assert f"function {member}" not in s and f"{member}(text" not in s, (
|
| 91 |
+
f"{name} implements {member}; turn behaviour belongs in avatar/turn-loop.js "
|
| 92 |
+
"or the iframe fallback silently loses it"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@pytest.mark.parametrize(
|
| 97 |
+
"member",
|
| 98 |
+
[
|
| 99 |
+
"mount",
|
| 100 |
+
"speak",
|
| 101 |
+
"replay",
|
| 102 |
+
"setListening",
|
| 103 |
+
"setThinking",
|
| 104 |
+
"on",
|
| 105 |
+
"getDebug",
|
| 106 |
+
"startListening",
|
| 107 |
+
"stopListening",
|
| 108 |
+
"dispatchTurn",
|
| 109 |
+
"requestSlower",
|
| 110 |
+
],
|
| 111 |
+
)
|
| 112 |
+
def test_surface_member_is_declared_once(member):
|
| 113 |
+
"""Every promised method must resolve in facade.js's surface list."""
|
| 114 |
+
assert member in avatar_surface()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@pytest.mark.parametrize("member", TURN_SURFACE)
|
| 118 |
+
def test_turn_surface_lives_in_the_shared_module(member):
|
| 119 |
+
assert member in src("turn-loop.js"), (
|
| 120 |
+
f"{member} must be defined in avatar/turn-loop.js so BOTH transports get it"
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@pytest.mark.parametrize(
|
| 125 |
+
"key",
|
| 126 |
+
[
|
| 127 |
+
"ready",
|
| 128 |
+
"mountCount",
|
| 129 |
+
"vrmMetaTitle",
|
| 130 |
+
"threeInstanceCount",
|
| 131 |
+
"currentVisemes",
|
| 132 |
+
"blinkValue",
|
| 133 |
+
"clockOffset",
|
| 134 |
+
"transport",
|
| 135 |
+
],
|
| 136 |
+
)
|
| 137 |
+
def test_debug_contract_keys_present(key):
|
| 138 |
+
joined = src("facade.js") + src("turn-loop.js") + src("vrm-stage.js")
|
| 139 |
+
assert key in joined, f"__debug.{key} is required by 01-VALIDATION.md Observable Signals"
|