/** * Browser smoke test. * * Drives the real app in real Chrome, where a real WebGL context and a real * AudioContext exist, and asserts on the acoustic behaviour the unit suite * cannot reach: that the view is actually sized, that turning your head * changes the binaural image, that walking away is audible, and that the * demos do what they claim. * * npm run test:e2e (against an already-running dev server) * npm run test:e2e -- */ import { spawn } from 'node:child_process'; import { writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { chromium } from 'playwright'; /** A real 16-bit PCM WAV, so the browser's own decoder is exercised. */ function makeWavFile(seconds, frequency, sampleRate = 44100) { const frames = Math.floor(seconds * sampleRate); const dataBytes = frames * 4; // stereo, 16-bit const buffer = Buffer.alloc(44 + dataBytes); buffer.write('RIFF', 0); buffer.writeUInt32LE(36 + dataBytes, 4); buffer.write('WAVE', 8); buffer.write('fmt ', 12); buffer.writeUInt32LE(16, 16); // PCM chunk size buffer.writeUInt16LE(1, 20); // PCM buffer.writeUInt16LE(2, 22); // channels buffer.writeUInt32LE(sampleRate, 24); buffer.writeUInt32LE(sampleRate * 4, 28); // byte rate buffer.writeUInt16LE(4, 32); // block align buffer.writeUInt16LE(16, 34); // bits per sample buffer.write('data', 36); buffer.writeUInt32LE(dataBytes, 40); for (let i = 0; i < frames; i++) { const sample = Math.round(Math.sin((2 * Math.PI * frequency * i) / sampleRate) * 20000); buffer.writeInt16LE(sample, 44 + i * 4); buffer.writeInt16LE(sample, 44 + i * 4 + 2); } return buffer; } let failures = 0; let checks = 0; let server = null; /** Boots a dev server unless the caller pointed us at one already. */ async function resolveUrl() { const explicit = process.argv[2]; if (explicit) return explicit; const port = 5179; const url = `http://localhost:${port}/`; server = spawn('npx', ['vite', '--port', String(port), '--strictPort'], { stdio: 'ignore', detached: false, }); for (let attempt = 0; attempt < 40; attempt++) { await new Promise((resolve) => setTimeout(resolve, 250)); try { const response = await fetch(url); if (response.ok) return url; } catch { /* not up yet */ } } throw new Error('dev server did not start'); } const url = await resolveUrl(); function check(name, condition, detail = '') { checks++; if (condition) { console.log(` ✓ ${name}`); } else { failures++; console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`); } } const browser = await chromium.launch({ channel: 'chrome', args: [ '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--autoplay-policy=no-user-gesture-required', ], }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: 'dark' }); const pageErrors = []; page.on('pageerror', (e) => pageErrors.push(e.message)); page.on('console', (m) => { if (m.type() === 'error' && !m.text().includes('favicon')) pageErrors.push(m.text()); }); const telemetry = () => page.evaluate(() => window.__resonance.telemetry()); const settings = () => page.evaluate(() => window.__resonance.settings()); const settle = (ms = 900) => page.waitForTimeout(ms); console.log(`\nResonance smoke test — ${url}\n`); await page.goto(url, { waitUntil: 'networkidle' }); await settle(1200); console.log('boot'); { const box = await page.evaluate(() => { const c = document.querySelector('#viewport canvas'); return { w: c.width, h: c.height, cssW: c.clientWidth, cssH: c.clientHeight }; }); check('the 3D view fills its pane', box.cssW > 800 && box.cssH > 400, JSON.stringify(box)); check('the drawing buffer matches the CSS box', box.w >= box.cssW && box.h >= box.cssH); check('the first-run dialog is shown', await page.locator('#welcome[open]').count() === 1); const t = await telemetry(); check('telemetry is live before audio is unlocked', t.distance > 0 && Number.isFinite(t.distanceGainDb)); check('audio is not yet running', t.running === false); } console.log('\nunlocking audio'); await page.locator('#welcome button', { hasText: 'Enable audio' }).click(); await settle(1500); { const t = await telemetry(); check('the context reports running', t.running === true); check('the status chip agrees', (await page.locator('#status').getAttribute('data-state')) === 'running'); check('the dialog closed', await page.locator('#welcome[open]').count() === 0); check('output has level', t.outputLevelDb > -80, `${t.outputLevelDb} dB`); } console.log('\nbinaural image (the camera is the microphone)'); { // Dry the room right out and use broadband noise. Reverb is diffuse by // construction, so a live room legitimately masks the binaural image — this // check is about HRTF, not about the direct-to-reverberant balance. await page.selectOption('#surface', 'anechoic'); await page.selectOption('#preset', 'pink'); await settle(900); const cameraBefore = await page.evaluate(() => window.__resonance.camera()); // Drag from low on the left, well clear of the transform gizmo — a drag that // starts on the gizmo moves the source instead of orbiting the camera. // Sample the ear balance right through the sweep rather than only at the // ends: a sweep that happens to land back on its starting bearing would // otherwise look like nothing moved. const box = await page.locator('#viewport canvas').boundingBox(); const startX = box.x + box.width * 0.18; const startY = box.y + box.height * 0.78; let min = Infinity; let max = -Infinity; await page.mouse.move(startX, startY); await page.mouse.down(); for (let i = 1; i <= 18; i++) { await page.mouse.move(startX + i * 22, startY); await page.waitForTimeout(90); const t = await telemetry(); const balance = t.leftLevelDb - t.rightLevelDb; if (Number.isFinite(balance)) { min = Math.min(min, balance); max = Math.max(max, balance); } } await page.mouse.up(); await settle(800); const cameraAfter = await page.evaluate(() => window.__resonance.camera()); const moved = Math.abs(cameraAfter.x - cameraBefore.x) + Math.abs(cameraAfter.z - cameraBefore.z); check('dragging orbits the camera', moved > 1, `moved ${moved.toFixed(2)} m`); check( 'orbiting swings the sound between the ears', max - min > 4, `L−R spanned ${min.toFixed(1)} … ${max.toFixed(1)} dB` ); await page.selectOption('#surface', 'studio'); await page.selectOption('#preset', 'sawtooth'); await settle(600); } console.log('\nwalking away'); { const before = await telemetry(); await page.locator('#viewport canvas').click({ position: { x: 500, y: 300 } }); await page.keyboard.down('KeyS'); await settle(1100); await page.keyboard.up('KeyS'); await settle(500); const after = await telemetry(); check( 'holding S increases the distance', after.distance > before.distance + 0.5, `${before.distance.toFixed(2)} m → ${after.distance.toFixed(2)} m` ); // Check the distance term specifically: walking also changes the off-axis // angle, so the *total* gain can legitimately rise as you back away. check( 'distance attenuation increases', after.distanceGainDb < before.distanceGainDb - 0.5, `${before.distanceGainDb.toFixed(1)} dB → ${after.distanceGainDb.toFixed(1)} dB` ); check( 'the reverberant field does not follow it down', Math.abs(after.reverbGainDb - before.reverbGainDb) < 0.5, `room ${before.reverbGainDb.toFixed(1)} dB → ${after.reverbGainDb.toFixed(1)} dB` ); } console.log('\ndemo 1 — siren fly-by'); { await page.keyboard.press('Digit1'); await settle(600); let sawShift = false; let extreme = 1; for (let i = 0; i < 40; i++) { const t = await telemetry(); if (Math.abs(t.dopplerCents) > 12) sawShift = true; if (Math.abs(t.dopplerCents) > Math.abs(extreme)) extreme = t.dopplerCents; await page.waitForTimeout(120); } check('a moving source produces a Doppler shift', sawShift, `peak ${extreme.toFixed(1)} cents`); check('the preset switched to the engine', (await settings()).preset === 'engine'); // A demo that changes the engine without updating the panel leaves every // control showing a stale value, and the next touch writes it back — silently // undoing the demo the user just asked for. check( 'the panel followed the demo', (await page.locator('#preset').inputValue()) === 'engine', `preset select shows "${await page.locator('#preset').inputValue()}"` ); check( 'the motion control followed the demo', (await page.locator('.segmented button[aria-checked="true"]').first().textContent())?.trim() === 'Fly-by' ); } console.log('\ndemo 2 — behind the pillar'); { await page.keyboard.press('Digit2'); await settle(1200); const t = await telemetry(); check('the line of sight is blocked', t.occlusion > 0.5, `occlusion ${t.occlusion.toFixed(2)}`); check('the direct path is lowpassed', t.occlusionCutoff < 4000, `${Math.round(t.occlusionCutoff)} Hz`); check('the signal path row flags it', await page.locator('.path tr[data-flag="warn"]').count() > 0); } console.log('\ndemo 3 — past the critical distance'); { await page.keyboard.press('Digit3'); await settle(1200); const t = await telemetry(); check('the room got livelier', t.t60 > 1.5, `t60 ${t.t60.toFixed(2)} s`); check('a critical distance is reported', t.criticalDistance > 0 && t.criticalDistance < 20); check('the room controls followed the demo', (await page.locator('#surface').inputValue()) === 'hall'); check('the width slider followed the demo', (await page.locator('#room-w').inputValue()) === '34'); check( 'direct-to-reverb is derived from the two levels shown', Math.abs(t.directToReverbDb - (t.directGainDb - t.reverbGainDb)) < 1e-6 ); } console.log('\nUI wiring'); { await page.selectOption('#surface', 'cathedral'); await settle(900); const stone = await telemetry(); await page.selectOption('#surface', 'anechoic'); await settle(900); const foam = await telemetry(); check('harder surfaces ring longer', stone.t60 > foam.t60, `${stone.t60.toFixed(2)} vs ${foam.t60.toFixed(2)} s`); check('deader surfaces push the critical distance out', foam.criticalDistance > stone.criticalDistance); await page.locator('#play').click(); await settle(500); check('pause stops playback', (await page.evaluate(() => window.__resonance.playing())) === false); await page.locator('#play').click(); await settle(500); check('play resumes it', (await page.evaluate(() => window.__resonance.playing())) === true); await page.locator('#hide-panels').click(); await settle(400); check('panels can be hidden', await page.evaluate(() => document.body.classList.contains('panels-hidden'))); await page.locator('#hide-panels').click(); await settle(400); const shown = await page.evaluate(() => { const rows = [...document.querySelectorAll('.path tr')]; const row = rows.find((r) => r.querySelector('th')?.textContent === 'Distance'); return row?.querySelector('td.detail')?.textContent ?? ''; }); const t = await telemetry(); check( 'the signal path prints the distance it measured', Math.abs(parseFloat(shown) - t.distance) < 0.2, `panel "${shown}" vs telemetry ${t.distance.toFixed(2)}` ); } console.log('\nloading a file from disk'); { const wav = makeWavFile(3, 220); const path = `${tmpdir()}/resonance-e2e-tone.wav`; writeFileSync(path, wav); await page.setInputFiles('#audio-file', path); await settle(2000); const settingsAfter = await settings(); check('the loaded file becomes the source', settingsAfter.preset === 'file'); check( 'the dropdown names the track', (await page.locator('#preset option[value="file"]').textContent())?.includes('resonance-e2e-tone.wav'), await page.locator('#preset option[value="file"]').textContent() ); check('the dropdown selects it', (await page.locator('#preset').inputValue()) === 'file'); check( 'the duration is reported', /0:03/.test((await page.locator('#audio-file-status').textContent()) ?? ''), await page.locator('#audio-file-status').textContent() ); check('playback started', (await page.evaluate(() => window.__resonance.playing())) === true); const t = await telemetry(); check('the file is producing output', t.outputLevelDb > -80, `${t.outputLevelDb.toFixed(1)} dBFS`); const sourceRow = await page.evaluate(() => { const rows = [...document.querySelectorAll('.path tr')]; const row = rows.find((r) => r.querySelector('th')?.textContent === 'Source'); return row?.querySelector('td.detail')?.textContent ?? ''; }); check( 'the signal path names the track, not "file"', sourceRow.includes('resonance-e2e-tone'), `shows "${sourceRow}"` ); // The whole point: a file behaves like every other source in the simulation. await page.locator('.segmented button', { hasText: 'Orbit' }).click(); await settle(1500); let min = Infinity; let max = -Infinity; for (let i = 0; i < 25; i++) { const s = await telemetry(); const balance = s.leftLevelDb - s.rightLevelDb; if (Number.isFinite(balance)) { min = Math.min(min, balance); max = Math.max(max, balance); } await page.waitForTimeout(150); } check( 'the file is spatialised like any other source', max - min > 3, `L−R spanned ${min.toFixed(1)} … ${max.toFixed(1)} dB` ); // A non-audio file must fail visibly, not silently or in the console. const junk = `${tmpdir()}/resonance-e2e-junk.txt`; writeFileSync(junk, 'this is not audio'); await page.setInputFiles('#audio-file', junk); await settle(1500); const status = (await page.locator('#audio-file-status').textContent()) ?? ''; check('a non-audio file reports a readable error', /Could not decode/.test(status), status); check( 'a failed load leaves the previous track playing', (await settings()).preset === 'file' && (await page.evaluate(() => window.__resonance.playing())) === true ); await page.locator('.segmented button', { hasText: 'Parked' }).click(); await page.selectOption('#preset', 'sawtooth'); await settle(600); } console.log('\naccessibility basics'); { const audit = await page.evaluate(() => { const controls = [...document.querySelectorAll('#panel input, #panel select')]; const unlabelled = controls.filter( (c) => !c.id || !document.querySelector(`label[for="${c.id}"]`) ).length; return { total: controls.length, unlabelled, headings: document.querySelectorAll('h1, h2').length, liveRegions: document.querySelectorAll('[aria-live]').length, canvasLabels: [...document.querySelectorAll('#strip canvas')].every((c) => c.getAttribute('aria-label') ), }; }); check('every panel control has a real label', audit.unlabelled === 0, `${audit.unlabelled} of ${audit.total}`); check('the page is structured with headings', audit.headings >= 4); check('there is a polite live region', audit.liveRegions >= 1); check('the meter canvases are described', audit.canvasLabels); } console.log('\nruntime health'); check('no uncaught page errors', pageErrors.length === 0, pageErrors.slice(0, 3).join(' | ')); await browser.close(); server?.kill(); console.log(`\n${checks - failures}/${checks} checks passed\n`); process.exit(failures === 0 ? 0 : 1);