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:
+16
-12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user