test(audio): Playwright E2E (3 tests, skip-if-no-browser) + client robustness
E2E asserts in a real headless Chromium: white_noise loads the noise asset, soundtrack loads the current scale's asset, and Visual=off fades video while audio keeps playing. Driving it out surfaced + fixed three client issues: - fadeVolume uses setInterval (rAF stalls when the tab is backgrounded) - start gesture drops srcless priming (a click grants Chrome autoplay document-wide; priming empty <audio> only hung the handler / polluted state) - applyAudio doesn't await play() (awaiting a srcless/buffering play can hang) pyproject: [e2e] optional dep (pytest-playwright). Full suite 288 passed, 2 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""Playwright E2E for the audio layer (audio spec §9). Skips cleanly when
|
||||
Playwright or its browser binary is absent (the headless dev box) — the unit +
|
||||
contract tiers cover the logic; this asserts the rendered <audio> element src and
|
||||
playback survives a Visual fade. Runs at the PPE stage where a browser exists.
|
||||
|
||||
Install: pip install -e '.[e2e]' && python -m playwright install chromium
|
||||
"""
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
|
||||
# Skip the whole module unless Playwright is importable.
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with closing(socket.socket()) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url():
|
||||
import uvicorn
|
||||
|
||||
from simulator.app import app
|
||||
|
||||
port = _free_port()
|
||||
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
for _ in range(50):
|
||||
if server.started:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
if not server.started:
|
||||
pytest.skip("uvicorn did not start")
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(app_url):
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
# Headless Chromium blocks media playback without a real audio device;
|
||||
# this flag lets play() actually start so `paused` is meaningful (the
|
||||
# spec §9 asserts playback via element src/paused, not a waveform).
|
||||
browser = p.chromium.launch(args=["--autoplay-policy=no-user-gesture-required"])
|
||||
except Exception as exc: # no browser binary installed
|
||||
pytest.skip(f"chromium not available: {exc}")
|
||||
pg = browser.new_page()
|
||||
pg.goto(app_url)
|
||||
pg.click("#start-gesture") # unlock autoplay
|
||||
yield pg
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_white_noise_loads_the_noise_asset(page):
|
||||
page.select_option("#audio", "white_noise")
|
||||
page.wait_for_function(
|
||||
"['audA','audB'].some(id => "
|
||||
"document.getElementById(id).src.includes('/media/audio/noise/pink.mp3'))"
|
||||
)
|
||||
|
||||
|
||||
def test_soundtrack_loads_the_current_scale_asset(page):
|
||||
# initial altitude is cosmos (index 0) → soundtrack resolves its scale asset,
|
||||
# not the noise bed (the altitude-coupling itself is covered at the API tier)
|
||||
page.select_option("#audio", "soundtrack")
|
||||
page.wait_for_function(
|
||||
"['audA','audB'].some(id => {"
|
||||
" const s = document.getElementById(id).src;"
|
||||
" return /\\/media\\/audio\\/.+\\.mp3/.test(s) && !s.includes('noise');"
|
||||
"})"
|
||||
)
|
||||
|
||||
|
||||
def test_visual_off_keeps_audio_and_fades_video(page):
|
||||
page.select_option("#audio", "white_noise")
|
||||
page.wait_for_function(
|
||||
"['audA','audB'].some(id => "
|
||||
"document.getElementById(id).src.includes('/media/audio/noise/pink.mp3'))"
|
||||
)
|
||||
page.uncheck("#visual")
|
||||
page.wait_for_selector("#black:not(.hidden)") # video faded to black
|
||||
playing = page.evaluate(
|
||||
"['audA','audB'].some(id => "
|
||||
"{const a = document.getElementById(id); return !a.paused && !!a.src;})"
|
||||
)
|
||||
assert playing is True # audio survives the video fade
|
||||
Reference in New Issue
Block a user