Assert sound is playing
Use case: a notification chime, a media player, a game. The regression you fear is silence — the element “plays” but no sound comes out (muted track, broken codec, autoplay policy, volume 0).
Why this is hard
Section titled “Why this is hard”Headless browsers have no speakers, and Playwright has no “is sound coming out” API. expect(el).toHaveJSProperty('paused', false) passes even when the output is silent — paused === false only means the element wants to play.
Recipe
Section titled “Recipe”Tap the element with an AnalyserNode
Section titled “Tap the element with an AnalyserNode”captureStream() clones a media element’s output as a MediaStream; an AnalyserNode then gives you the waveform. Non-zero RMS energy = audible signal:
import { test, expect } from '@playwright/test';
async function audioRms(page, selector: string) { return page.evaluate(async (sel) => { const el = document.querySelector(sel) as HTMLMediaElement; const stream = (el as any).captureStream?.() ?? (el as any).mozCaptureStream(); const ctx = new AudioContext(); const analyser = ctx.createAnalyser(); ctx.createMediaStreamSource(stream).connect(analyser); await new Promise((r) => setTimeout(r, 300)); const data = new Float32Array(analyser.fftSize); analyser.getFloatTimeDomainData(data); return Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length); }, selector);}
test('notification chime is audible', async ({ page }) => { await page.goto('/inbox'); await page.getByRole('button', { name: 'Send' }).click();
await expect.poll(() => audioRms(page, '#chime')).toBeGreaterThan(0.01);});Assert on what is playing, not just that it plays
Section titled “Assert on what is playing, not just that it plays”Frequency data distinguishes a chime from a buzz — useful when several sounds share one element:
const dominantHz = await page.evaluate(() => { const analyser = (window as any).__analyser; // set up as above const bins = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(bins); const peak = bins.indexOf(Math.max(...bins)); return (peak * analyser.context.sampleRate) / 2 / bins.length;});expect(dominantHz).toBeGreaterThan(800); // the chime is ~880 Hzexpect(dominantHz).toBeLessThan(950);Web Audio apps (no media element)
Section titled “Web Audio apps (no media element)”If sound comes from AudioContext directly (games, synths), capture the app’s context via an init script and connect your analyser to its destination:
await page.addInitScript(() => { const Orig = window.AudioContext; window.AudioContext = class extends Orig { constructor(...args: any[]) { super(...args); (window as any).__audioCtx = this; } };});Then create the analyser inside page.evaluate, connecting __audioCtx.destination’s inputs through it.
Caveats
Section titled “Caveats”- Launch Chromium with
--autoplay-policy=no-user-gesture-requiredor the context may be suspended before any user gesture. captureStream()on media elements is not implemented in WebKit — run these assertions on Chromium/Firefox, or route through Web Audio.- Muted elements (
el.muted = true) still produce energy incaptureStream()on some browsers — assertel.muted === falseseparately if muting is the bug you’re hunting.
Related
Section titled “Related”- Mock microphone input — the input side.
- Test WebRTC calls — the same RMS trick applied to a live call.