WolfDavid commited on
Commit
f13c9e2
Β·
1 Parent(s): b4ca58d

test(01-07): prove the gate, the tier fallback and push-to-talk under both transports

Browse files

- tests/e2e/test_asr_standalone.py: 7 local browser tests driving Chromium with each
fixture as its microphone. Silence and cafe noise produce zero transcripts between
them, and the cafe rejection is asserted on the envelope-modulation condition
specifically, so the gate cannot silently decay into an RMS floor
- the suite runs the harness with browser audio processing OFF: with noise suppression
on, Chromium drops the cafe fixture to 0.0055 RMS and the modulation condition is
never reached, which would be green and meaningless
- test_tier_wasm_fallback removes navigator.gpu outright, because the launch flag alone
leaves the API surface in place in Chromium 151
- test_facade_parity.py: the deferred-method test now scopes to plan 01-08's two
remaining stubs rather than being deleted, plus a new case asserting startListening
and stopListening exist and no longer throw under BOTH transports

tests/e2e/test_asr_standalone.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Push-to-talk, the pre-ASR gate and the tier fallback, proven in a real browser.
2
+
3
+ Local counterparts of three rows in 01-VALIDATION.md. Those rows' names -
4
+ ``test_ptt_turn``, ``test_silence_rejected``, ``test_asr_wasm_fallback`` - belong to the
5
+ DEPLOYED suite in plan 01-09 and are deliberately not reused here, so the phase verifier
6
+ cannot mistake a local pass for a deployed one. Everything below runs against a static
7
+ server on loopback and needs no Space.
8
+
9
+ Two things about the environment shape these tests, and both were measured rather than
10
+ assumed:
11
+
12
+ 1. **Chromium's WebRTC audio processing is very good at steady noise.** With
13
+ ``noiseSuppression`` on, the committed cafe fixture arrives at RMS 0.0055 instead of
14
+ 0.0577 and is rejected by the RMS floor before the envelope-modulation condition is
15
+ ever consulted. Green, and meaningless. The harness is therefore driven with
16
+ ``?processing=off`` so the gate is verified in the PESSIMISTIC configuration - a raw
17
+ microphone, which is what a browser without WebRTC processing hands us anyway.
18
+ 2. **Headless Chromium exposes ``navigator.gpu`` but returns a null adapter.** So the
19
+ default launch already exercises the branch that matters most - the API is present,
20
+ the adapter probe says no, and the runtime must never issue the WebGPU call that would
21
+ poison it for the rest of the page. ``test_tier_wasm_fallback`` covers the other
22
+ branch, where the WebGPU API is absent entirely.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import functools
28
+ import http.server
29
+ import socket
30
+ import tempfile
31
+ import threading
32
+ from pathlib import Path
33
+
34
+ import pytest
35
+
36
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
37
+ FIXTURES = REPO_ROOT / "tests" / "fixtures"
38
+
39
+ # Driven with the browser's own audio processing disabled - see the module docstring.
40
+ HARNESS = "/avatar/asr-harness.html?processing=off"
41
+ HARNESS_READY = "() => window.__harnessReady === true"
42
+ READY_TIMEOUT_MS = 60_000
43
+ # A first whisper-base q4 load is tens of megabytes over the network plus session build.
44
+ MODEL_TIMEOUT_MS = 600_000
45
+
46
+ # A fixed port keeps the browser Cache API origin stable between runs, which is the only
47
+ # reason the model files survive from one invocation to the next.
48
+ PREFERRED_PORT = 8478
49
+
50
+ # The model files live in the browser profile, so a stable profile directory is what
51
+ # turns the second run of this suite from a download into a disk read.
52
+ MODEL_PROFILE = Path(tempfile.gettempdir()) / "jla-asr-chromium-profile"
53
+
54
+ BASE_ARGS = [
55
+ "--autoplay-policy=no-user-gesture-required",
56
+ "--use-fake-ui-for-media-stream",
57
+ "--use-fake-device-for-media-stream",
58
+ ]
59
+
60
+ # Chromium 151 keeps navigator.gpu defined even with WebGPU disabled by launch flag: it
61
+ # stops an adapter being handed out but leaves the API surface in place. Since the whole
62
+ # point of test_tier_wasm_fallback is the branch where the API is ABSENT - a Firefox
63
+ # before 141, a Safari before 26 - the property is removed in the page as well.
64
+ # A bare statement, not an arrow function: add_init_script evaluates the string, so a
65
+ # function expression would be constructed and thrown away without ever running.
66
+ HIDE_WEBGPU = "try { delete Navigator.prototype.gpu; } catch (e) {}"
67
+
68
+ # Reject reasons, mirrored from avatar/mic.js REJECT. Asserting on the specific condition
69
+ # rather than merely "it was rejected" is what stops the gate silently degrading into an
70
+ # RMS floor the day someone loosens the modulation threshold.
71
+ REASON_DURATION = "duration-floor"
72
+ REASON_RMS = "rms-floor"
73
+ REASON_MODULATION = "envelope-modulation"
74
+
75
+
76
+ def audio_arg(wav: str) -> str:
77
+ """Chromium's fake audio capture wants 16-bit PCM WAV; the fixtures already are."""
78
+ return f"--use-file-for-fake-audio-capture={(FIXTURES / wav).as_posix()}%noloop"
79
+
80
+
81
+ def _free_port() -> int:
82
+ with socket.socket() as s:
83
+ s.bind(("127.0.0.1", 0))
84
+ return s.getsockname()[1]
85
+
86
+
87
+ @pytest.fixture(scope="module")
88
+ def asr_server() -> str:
89
+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT))
90
+ try:
91
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", PREFERRED_PORT), handler)
92
+ except OSError:
93
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", _free_port()), handler)
94
+ server.daemon_threads = True
95
+ threading.Thread(target=server.serve_forever, daemon=True).start()
96
+ try:
97
+ yield f"http://127.0.0.1:{server.server_port}"
98
+ finally:
99
+ server.shutdown()
100
+ server.server_close()
101
+
102
+
103
+ CAPTURE = """
104
+ async () => ({
105
+ transcripts: window.__events('transcript').map((e) => e.data),
106
+ tiers: window.__events('asr-tier').map((e) => e.data),
107
+ listening: window.__events('listening').map((e) => e.data),
108
+ errors: window.__events('error').map((e) => e.data),
109
+ pageErrors: [...window.__pageErrors],
110
+ asr: window.__asrDebug(),
111
+ })
112
+ """
113
+
114
+
115
+ def _run_push(
116
+ playwright, server: str, wav: str, hold_ms: int, extra_args=(), cached=False, hide_webgpu=False
117
+ ):
118
+ """One push-to-talk cycle in a browser fed `wav` as its microphone."""
119
+ args = [*BASE_ARGS, audio_arg(wav), *extra_args]
120
+ console: list[str] = []
121
+ if cached:
122
+ MODEL_PROFILE.mkdir(parents=True, exist_ok=True)
123
+ context = playwright.chromium.launch_persistent_context(
124
+ user_data_dir=str(MODEL_PROFILE), args=args
125
+ )
126
+ browser = None
127
+ else:
128
+ browser = playwright.chromium.launch(args=args)
129
+ context = browser.new_context()
130
+ try:
131
+ if hide_webgpu:
132
+ context.add_init_script(HIDE_WEBGPU)
133
+ page = context.new_page()
134
+ page.on("console", lambda m: console.append(f"{m.type}: {m.text}"))
135
+ page.on("pageerror", lambda e: console.append(f"pageerror: {e}"))
136
+ page.set_default_timeout(MODEL_TIMEOUT_MS)
137
+ page.goto(f"{server}{HARNESS}")
138
+ page.wait_for_function(HARNESS_READY, timeout=READY_TIMEOUT_MS)
139
+ pushed = page.evaluate("async (hold) => await window.__push(hold)", hold_ms)
140
+ captured = page.evaluate(CAPTURE)
141
+ return {**pushed, **captured, "console": console}
142
+ finally:
143
+ context.close()
144
+ if browser is not None:
145
+ browser.close()
146
+
147
+
148
+ @pytest.fixture(scope="module")
149
+ def silence_push(playwright, asr_server):
150
+ return _run_push(playwright, asr_server, "silence_30s.wav", 3000)
151
+
152
+
153
+ @pytest.fixture(scope="module")
154
+ def cafe_push(playwright, asr_server):
155
+ return _run_push(playwright, asr_server, "cafe_noise_30s.wav", 3000)
156
+
157
+
158
+ @pytest.fixture(scope="module")
159
+ def short_push(playwright, asr_server):
160
+ return _run_push(playwright, asr_server, "speech_ja.wav", 150)
161
+
162
+
163
+ @pytest.fixture(scope="module")
164
+ def speech_push(playwright, asr_server):
165
+ """The only fixture that loads a model on the default (WebGPU-attempted) launch."""
166
+ return _run_push(playwright, asr_server, "speech_ja.wav", 1400, cached=True)
167
+
168
+
169
+ @pytest.fixture(scope="module")
170
+ def no_webgpu_push(playwright, asr_server):
171
+ """WebGPU forcibly off: navigator.gpu is absent, so no attempt is even made."""
172
+ return _run_push(
173
+ playwright,
174
+ asr_server,
175
+ "speech_ja.wav",
176
+ 1400,
177
+ extra_args=["--disable-features=WebGPU", "--disable-gpu"],
178
+ hide_webgpu=True,
179
+ cached=True,
180
+ )
181
+
182
+
183
+ @pytest.fixture(scope="module")
184
+ def blocklist_page(playwright, asr_server):
185
+ """No microphone and no model: the blocklist is a pure function of text + duration."""
186
+ browser = playwright.chromium.launch(args=BASE_ARGS)
187
+ try:
188
+ page = browser.new_page()
189
+ page.goto(f"{asr_server}{HARNESS}")
190
+ page.wait_for_function(HARNESS_READY, timeout=READY_TIMEOUT_MS)
191
+ yield page
192
+ finally:
193
+ browser.close()
194
+
195
+
196
+ def test_gate_rejects_silence(silence_push):
197
+ """30 s of silence must not become an utterance. The RMS floor is what catches it."""
198
+ mic = silence_push["mic"]
199
+ assert silence_push["started"] is True, "capture never started, so nothing was gated"
200
+ assert mic["lastRejectReason"] == REASON_RMS, (
201
+ f"silence was rejected for {mic['lastRejectReason']!r}, not the RMS floor; "
202
+ f"measured rms={mic['lastRms']:.5f} over {mic['lastDurationMs']:.0f} ms"
203
+ )
204
+ assert mic["acceptedCount"] == 0
205
+ assert mic["rejectedCount"] == 1
206
+ assert silence_push["transcripts"] == [], (
207
+ "a transcript escaped from silence; this is the Whisper-hallucination failure "
208
+ f"the gate exists to prevent: {silence_push['transcripts']}"
209
+ )
210
+ assert silence_push["text"] is None
211
+
212
+
213
+ def test_gate_rejects_cafe_noise(cafe_push, silence_push):
214
+ """The assertion that proves the gate is more than an RMS floor.
215
+
216
+ The cafe fixture is written at -24.8 dBFS specifically so it sails past any plausible
217
+ RMS threshold. It is rejected because steady broadband noise has no envelope
218
+ modulation, which is the only property that actually distinguishes it from speech.
219
+ """
220
+ mic = cafe_push["mic"]
221
+ assert cafe_push["started"] is True
222
+ assert mic["lastRms"] > 0.01, (
223
+ f"the cafe fixture arrived at rms={mic['lastRms']:.5f}, below the RMS floor, so "
224
+ "the modulation condition was never reached and this test proves nothing"
225
+ )
226
+ assert mic["lastRejectReason"] == REASON_MODULATION, (
227
+ f"cafe noise was rejected for {mic['lastRejectReason']!r}; the envelope-modulation "
228
+ f"condition is the one that must fire. measured modulation={mic['lastModulation']:.3f}"
229
+ )
230
+ assert mic["lastModulation"] < 2.5
231
+ assert mic["acceptedCount"] == 0
232
+ assert cafe_push["transcripts"] == []
233
+
234
+ # 01-VALIDATION.md VOIC-02: silence and noise produce ZERO avatar turns, not few.
235
+ assert len(cafe_push["transcripts"]) + len(silence_push["transcripts"]) == 0
236
+
237
+
238
+ def test_gate_rejects_short_push(short_push):
239
+ """A stray tap on the control is not an utterance.
240
+
241
+ A 150 ms hold does not survive getUserMedia's own start-up latency, so the recording
242
+ is empty - which is a zero-length recording, i.e. the duration floor, not a separate
243
+ failure mode nobody could act on.
244
+ """
245
+ mic = short_push["mic"]
246
+ assert mic["lastRejectReason"] == REASON_DURATION, (
247
+ f"a 150 ms push was rejected for {mic['lastRejectReason']!r}, not the duration floor"
248
+ )
249
+ assert mic["lastDurationMs"] < 300
250
+ assert mic["acceptedCount"] == 0
251
+ assert short_push["transcripts"] == []
252
+
253
+
254
+ @pytest.mark.slow
255
+ def test_gate_accepts_speech(speech_push):
256
+ """The other half of the gate: real Japanese must get through and be transcribed."""
257
+ mic = speech_push["mic"]
258
+ assert mic["lastRejectReason"] is None, (
259
+ f"speech_ja.wav was rejected for {mic['lastRejectReason']!r}; "
260
+ f"rms={mic['lastRms']:.5f} modulation={mic['lastModulation']:.3f}"
261
+ )
262
+ assert mic["acceptedCount"] == 1
263
+ assert mic["lastModulation"] >= 2.5
264
+ assert len(speech_push["transcripts"]) == 1, (
265
+ f"expected exactly one transcript, got {speech_push['transcripts']}"
266
+ )
267
+ event = speech_push["transcripts"][0]
268
+ assert event["text"].strip(), "the transcript event fired with empty text"
269
+ assert event["gated"] is False
270
+ assert event["tier"] in {"webgpu", "wasm"}
271
+ assert speech_push["text"] == event["text"]
272
+
273
+
274
+ @pytest.mark.slow
275
+ def test_tier_reports_active_backend(speech_push, record_property):
276
+ """Announcing the active tier is a product requirement, not debug output.
277
+
278
+ Headless Chromium exposes navigator.gpu but hands back a null adapter, so this run
279
+ also proves the catch-and-re-instantiate path is real: the attempt is made, it fails,
280
+ the failure is recorded, and the user still gets a transcript.
281
+ """
282
+ tiers = speech_push["tiers"]
283
+ assert len(tiers) == 1, f"expected exactly one asr-tier announcement, got {tiers}"
284
+ tier = tiers[0]
285
+ assert tier["tier"] in {"webgpu", "wasm"}
286
+ assert tier["loadMs"] > 0
287
+ assert tier["model"] and tier["dtype"]
288
+ record_property("asr_tier", tier["tier"])
289
+ record_property("asr_load_ms", tier["loadMs"])
290
+
291
+ asr = speech_push["asr"]
292
+ if asr["webgpuPresent"] and tier["tier"] == "wasm":
293
+ assert asr["webgpuAvailable"] is False, (
294
+ "the WASM tier was chosen while an adapter was available; the tier report is "
295
+ "not describing what actually ran"
296
+ )
297
+ assert asr["webgpuError"], (
298
+ "navigator.gpu was present and the WASM tier was chosen, but no reason was "
299
+ "recorded - the fallback happened for an unexplained reason"
300
+ )
301
+
302
+
303
+ @pytest.mark.slow
304
+ def test_tier_wasm_fallback(no_webgpu_push, record_property):
305
+ """The local rehearsal of the deployed test_asr_wasm_fallback.
306
+
307
+ WebGPU forcibly off. The tier must be wasm, transcription must still work, and no
308
+ uncaught exception may reach the page - a broken app is not an acceptable degradation.
309
+ """
310
+ asr = no_webgpu_push["asr"]
311
+ assert asr["webgpuPresent"] is False, (
312
+ "navigator.gpu survived into the page, so this run is not actually testing the "
313
+ "branch where the WebGPU API is absent"
314
+ )
315
+ assert asr["webgpuAvailable"] is False
316
+ tiers = no_webgpu_push["tiers"]
317
+ assert len(tiers) == 1 and tiers[0]["tier"] == "wasm", f"expected the WASM tier, got {tiers}"
318
+ assert tiers[0]["loadMs"] > 0
319
+ record_property("wasm_load_ms", tiers[0]["loadMs"])
320
+
321
+ assert no_webgpu_push["pageErrors"] == [], (
322
+ f"an uncaught exception reached the page on the fallback path: "
323
+ f"{no_webgpu_push['pageErrors']}"
324
+ )
325
+ assert no_webgpu_push["errors"] == [], (
326
+ f"the facade emitted error events on the fallback path: {no_webgpu_push['errors']}"
327
+ )
328
+ assert len(no_webgpu_push["transcripts"]) == 1, (
329
+ f"WASM-only transcription produced {no_webgpu_push['transcripts']}"
330
+ )
331
+ assert no_webgpu_push["transcripts"][0]["text"].strip()
332
+
333
+
334
+ def test_blocklist_only_applies_to_short_pushes(blocklist_page):
335
+ """The blocklist must not swallow ordinary Japanese.
336
+
337
+ The bare polite form is a thing a learner says. The subtitle-boilerplate form is not,
338
+ but over 1.5 s of audio even that is more likely to be a real sentence than a
339
+ hallucination, so the blocklist stops applying.
340
+ """
341
+ ordinary = "γ‚γ‚ŠγŒγ¨γ†γ”γ–γ„γΎγ—γŸ"
342
+ boilerplate = "ご視聴" + ordinary
343
+
344
+ def check(text: str, duration_ms: int) -> bool:
345
+ return blocklist_page.evaluate(
346
+ "([t, d]) => window.__isHallucination(t, d)", [text, duration_ms]
347
+ )
348
+
349
+ assert check(ordinary, 500) is False, "the bare polite form was blocklisted"
350
+ assert check(ordinary, 5000) is False
351
+ assert check(boilerplate, 500) is True, "subtitle boilerplate passed on a short push"
352
+ assert check(boilerplate, 1499) is True
353
+ assert check(boilerplate, 1500) is False, (
354
+ "the blocklist still applied at 1.5 s; past that length the string is far more "
355
+ "likely to be something the learner actually said"
356
+ )
357
+ # Trailing Japanese punctuation must not let boilerplate through.
358
+ assert check(boilerplate + "。", 500) is True
tests/e2e/test_facade_parity.py CHANGED
@@ -23,9 +23,18 @@ 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 {
@@ -37,6 +46,23 @@ async (name) => {
37
  }
38
  """
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  @pytest.fixture(scope="session")
42
  def live_avatars(browser, gradio_apps):
@@ -57,6 +83,7 @@ def live_avatars(browser, gradio_apps):
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()
@@ -90,9 +117,11 @@ def test_transports_expose_identical_debug_keys(live_avatars):
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():
@@ -104,3 +133,21 @@ def test_deferred_methods_fail_loudly_not_silently(live_avatars):
104
  f"{transport}: Avatar.{name}() threw {outcome['message']!r}, which does "
105
  f"not name plan {DEFERRED_PLAN}"
106
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ #
27
+ # This tuple shrinks as waves land. Plan 01-07 implemented startListening/stopListening,
28
+ # so they moved OUT of here and into WIRED_METHODS below; only plan 01-08's two remain.
29
+ # The test itself is not deleted until the tuple is empty - a deferred stub that has
30
+ # quietly become a no-op is exactly the drift this suite exists to catch.
31
  DEFERRED_PLAN = "01-08"
32
  DEFERRED_METHODS = ("dispatchTurn", "requestSlower")
33
 
34
+ # Implemented and expected to work under BOTH transports.
35
+ WIRED_PLAN = "01-07"
36
+ WIRED_METHODS = ("startListening", "stopListening")
37
+
38
  CALL_DEFERRED = """
39
  async (name) => {
40
  try {
 
46
  }
47
  """
48
 
49
+ # Type check plus a real call. A method that exists but still throws the not-wired error
50
+ # would pass a typeof check and fail a learner, so both halves are needed.
51
+ PROBE_WIRED = """
52
+ async (name) => {
53
+ const isFunction = typeof window.Avatar[name] === 'function';
54
+ let notWired = false;
55
+ let message = '';
56
+ try {
57
+ await window.Avatar[name]();
58
+ } catch (err) {
59
+ message = String((err && err.message) || err);
60
+ notWired = message.includes('not wired yet');
61
+ }
62
+ return { isFunction, notWired, message };
63
+ }
64
+ """
65
+
66
 
67
  @pytest.fixture(scope="session")
68
  def live_avatars(browser, gradio_apps):
 
83
  ),
84
  "reported_transport": page.evaluate("() => window.Avatar.__debug.transport"),
85
  "deferred": {name: page.evaluate(CALL_DEFERRED, name) for name in DEFERRED_METHODS},
86
+ "wired": {name: page.evaluate(PROBE_WIRED, name) for name in WIRED_METHODS},
87
  }
88
  finally:
89
  page.close()
 
117
 
118
 
119
  def test_deferred_methods_fail_loudly_not_silently(live_avatars):
120
+ """The remaining stubs are placeholders, not accidental no-ops.
121
 
122
+ Scope shrinks with each wave rather than the test being deleted: plan 01-07 removed
123
+ startListening/stopListening from DEFERRED_METHODS when it implemented them, and plan
124
+ 01-08 empties the tuple. Whatever is still deferred must still fail loudly.
125
  """
126
  for transport in TRANSPORTS:
127
  for name, outcome in live_avatars[transport]["deferred"].items():
 
133
  f"{transport}: Avatar.{name}() threw {outcome['message']!r}, which does "
134
  f"not name plan {DEFERRED_PLAN}"
135
  )
136
+
137
+
138
+ def test_push_to_talk_exists_under_both_transports(live_avatars):
139
+ """The payoff of putting the wiring in avatar/turn-loop.js.
140
+
141
+ The iframe transport gained push-to-talk without gaining a line of code, and this is
142
+ the assertion that proves it against a LIVE object rather than against a grep.
143
+ """
144
+ for transport in TRANSPORTS:
145
+ for name, outcome in live_avatars[transport]["wired"].items():
146
+ assert outcome["isFunction"], (
147
+ f"{transport}: Avatar.{name} is not a function; the shared turn loop did "
148
+ "not reach this transport"
149
+ )
150
+ assert not outcome["notWired"], (
151
+ f"{transport}: Avatar.{name}() still throws the plan {WIRED_PLAN} "
152
+ f"not-wired error: {outcome['message']!r}"
153
+ )