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:
BenStullsBets
2026-06-26 08:00:44 -07:00
parent fd0aad6610
commit c07067492b
3 changed files with 120 additions and 12 deletions
+16 -12
View File
@@ -908,15 +908,17 @@ let audioReady = false; // unlocked after the first user gesture
const XFADE_MS = 600; // ~0.6s gain crossfade — "live", no busy ack (§7)
function fadeVolume(el, to, ms) {
const from = el.volume, t0 = performance.now();
// setInterval (not requestAnimationFrame): rAF throttles/pauses when the tab is
// backgrounded, which would stall the gain ramp; a timer fires regardless.
const from = el.volume, steps = Math.max(1, Math.round(ms / 30));
let i = 0;
return new Promise((resolve) => {
function tick(now) {
const k = Math.min(1, (now - t0) / ms);
el.volume = from + (to - from) * k;
if (k < 1) requestAnimationFrame(tick);
else resolve();
}
requestAnimationFrame(tick);
const iv = setInterval(() => {
i += 1;
const k = Math.min(1, i / steps);
el.volume = Math.min(1, Math.max(0, from + (to - from) * k));
if (k >= 1) { clearInterval(iv); resolve(); }
}, 30);
});
}
@@ -936,7 +938,7 @@ async function applyAudio(audio) {
}
audIdle.src = mediaUrl(url); // absolute /media/... passes through mediaUrl
audIdle.volume = 0;
await audIdle.play().catch(() => {});
audIdle.play().catch(() => {}); // start (fire-and-forget — awaiting can hang)
await Promise.all([fadeVolume(audIdle, 1, XFADE_MS), fadeVolume(audActive, 0, XFADE_MS)]);
audActive.pause();
[audActive, audIdle] = [audIdle, audActive]; // swap roles
@@ -946,13 +948,15 @@ async function applyAudio(audio) {
// full-screen by the operator; a one-time tap unlocks audio, then it follows
// state (audio spec §8 — the documented launch step).
function showStartGesture() {
if (audioReady || document.getElementById("start-gesture")) return;
const ov = document.createElement("div");
ov.id = "start-gesture";
ov.textContent = "▶ tap to start sound";
ov.addEventListener("click", async () => {
ov.addEventListener("click", () => {
// A click anywhere on the page grants Chrome's autoplay permission
// document-wide for the session, so later programmatic play() is allowed —
// no per-element priming needed (priming srcless elements only pollutes state).
audioReady = true;
// prime both elements within the gesture so later play() calls are allowed
for (const el of [audA, audB]) { try { await el.play(); el.pause(); } catch (_) {} }
ov.remove();
audUrl = null; // force the next applyAudio to (re)load
update(); // re-apply current state, now with audio