diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3146007d..51f775e8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -285,6 +285,8 @@ jobs: BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-live-1297-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-lab-1297-e2e.js 2>&1 | tee -a e2e-output.txt - name: Collect frontend coverage (parallel) if: success() && github.event_name == 'push' diff --git a/test-audio-lab-1297-e2e.js b/test-audio-lab-1297-e2e.js new file mode 100644 index 00000000..08d0429a --- /dev/null +++ b/test-audio-lab-1297-e2e.js @@ -0,0 +1,268 @@ +#!/usr/bin/env node +/* Issue #1297 — B1 audio-lab coverage. + * + * Exercises public/audio-lab.js (562 LOC, was 4.2% coverage) via /#/audio-lab. + * + * The page calls GET /api/audio-lab/buckets — we INTERCEPT this in the test + * with a deterministic stub so coverage doesn't depend on whether the CI + * fixture has the right packet mix. This still exercises the production + * code path (no source patching) — just a network-level stub. + * + * Asserts: + * (a) page mounts, sidebar lists at least 2 type buckets + * (b) clicking a packet selects it (`.alab-pkt.selected` count = 1) + * (c) selection populates #alabDetail with hex (.alab-hex), note table + * (.alab-note-table), byte viz (.alab-byte-viz) — exercises + * renderDetail + computeMapping + * (d) clicking a type header toggles its packet list visibility + * (`[data-type-list="..."].style.display`) + * (e) BPM slider updates #alabBPMVal text + * (f) Volume slider updates #alabVolVal text and MeshAudio.getVolume() + * (g) Speed buttons toggle .active class on .alab-speed (covers speed switch handler) + * (h) Loop button toggles .active class on #alabLoop + * (i) Play button triggers MeshAudio.sonifyPacket (oscillator count increments + * with stubbed AudioContext) + * (j) destroy(): navigating away clears styles + timers + * + * Stable selectors: #alabSidebar, #alabPlay, #alabLoop, #alabBPM, #alabVol, + * #alabBPMVal, #alabVolVal, #alabVoice, .alab-pkt, .alab-type-hdr, + * .alab-speed, #alabDetail, .alab-hex, .alab-note-table, .alab-byte-viz + * + * CI gating: CHROMIUM_REQUIRE=1 → HARD FAIL on missing Chromium. + */ +'use strict'; + +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +// Deterministic stub for /api/audio-lab/buckets — two types, two packets each. +const FAKE_BUCKETS = { + buckets: { + ADVERT: [ + { raw_hex: 'a1b2c3' + '00112233445566778899aabbccddeeff' + '1020', observation_count: 3 }, + { raw_hex: 'a1b2c4' + 'ff'.repeat(20), observation_count: 1 }, + ], + GRP_TXT: [ + { raw_hex: 'b1c2d3' + '01020304050607080910111213141516', observation_count: 5 }, + ], + TXT_MSG: [ + { raw_hex: 'c1d2e3' + 'aa'.repeat(24), observation_count: 2 }, + ], + }, +}; + +async function main() { + const requireChromium = process.env.CHROMIUM_REQUIRE === '1'; + let browser; + try { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + } catch (err) { + if (requireChromium) { + console.error(`test-audio-lab-1297-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`); + process.exit(1); + } + console.log(`test-audio-lab-1297-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`); + process.exit(0); + } + + let failures = 0; + let passes = 0; + const fail = (msg) => { failures += 1; console.error(` FAIL: ${msg}`); }; + const pass = (msg) => { passes += 1; console.log(` PASS: ${msg}`); }; + + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + + // Stub AudioContext so playback paths don't crash on headless chromium. + await page.addInitScript(() => { + window.__audioStub = { oscillators: 0, gainNodes: 0 }; + function makeNode() { + return { + connect() { return makeNode(); }, disconnect() {}, + start() {}, stop() {}, + gain: { value: 0, setValueAtTime() {}, linearRampToValueAtTime() {}, exponentialRampToValueAtTime() {} }, + frequency: { value: 0, setValueAtTime() {}, linearRampToValueAtTime() {} }, + Q: { value: 0 }, pan: { value: 0 }, + threshold: { value: 0 }, knee: { value: 0 }, ratio: { value: 0 }, + attack: { value: 0 }, release: { value: 0 }, + type: 'sine', + }; + } + class FakeAudioContext { + constructor() { this.state = 'running'; this.currentTime = 0; this.destination = makeNode(); } + createGain() { window.__audioStub.gainNodes += 1; return makeNode(); } + createOscillator() { window.__audioStub.oscillators += 1; return makeNode(); } + createBiquadFilter() { return makeNode(); } + createDynamicsCompressor() { return makeNode(); } + createStereoPanner() { return makeNode(); } + createPanner() { return makeNode(); } + resume() { this.state = 'running'; return Promise.resolve(); } + } + window.AudioContext = FakeAudioContext; + window.webkitAudioContext = FakeAudioContext; + }); + + // Intercept the buckets API with deterministic data. + await page.route('**/api/audio-lab/buckets', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(FAKE_BUCKETS), + }); + }); + + // Visit the live page first so MeshAudio + voice modules are loaded + await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.MeshAudio); + + // Now navigate to audio-lab + await page.goto(`${BASE}/#/audio-lab`, { waitUntil: 'domcontentloaded' }); + // Wait for sidebar to populate (post-fetch) + await page.waitForSelector('#alabSidebar .alab-type-hdr', { timeout: 10000 }).catch(() => {}); + + // (a) sidebar populated + const typeCount = await page.evaluate(() => document.querySelectorAll('#alabSidebar .alab-type-hdr').length); + if (typeCount >= 2) pass(`sidebar shows ${typeCount} type headers`); + else fail(`sidebar only shows ${typeCount} type headers (expected >=2)`); + + const pktCount = await page.evaluate(() => document.querySelectorAll('#alabSidebar .alab-pkt').length); + if (pktCount >= 3) pass(`sidebar shows ${pktCount} packet entries`); + else fail(`sidebar only shows ${pktCount} packets (expected >=3)`); + + // (b) clicking selects a packet + await page.click('#alabSidebar .alab-pkt'); + const selected = await page.evaluate(() => document.querySelectorAll('#alabSidebar .alab-pkt.selected').length); + if (selected === 1) pass('exactly one packet selected after click'); + else fail(`selected packet count = ${selected}`); + + // (c) detail populated + await page.waitForSelector('#alabDetail .alab-hex', { timeout: 5000 }).catch(() => {}); + const detailSummary = await page.evaluate(() => ({ + hex: !!document.querySelector('#alabDetail .alab-hex'), + notes: document.querySelectorAll('#alabDetail .alab-note-table tr').length, + bars: document.querySelectorAll('#alabDetail .alab-byte-bar').length, + mapTable: !!document.querySelector('#alabDetail .alab-map-table'), + })); + if (detailSummary.hex && detailSummary.notes >= 2 && detailSummary.bars >= 3) { + pass(`detail rendered: hex=yes notes=${detailSummary.notes} bars=${detailSummary.bars}`); + } else { + fail(`detail incomplete: ${JSON.stringify(detailSummary)}`); + } + + // (d) type header toggles list visibility + const firstType = await page.evaluate(() => + document.querySelector('#alabSidebar .alab-type-hdr').dataset.type + ); + await page.click(`#alabSidebar .alab-type-hdr[data-type="${firstType}"]`); + const hidden = await page.evaluate((t) => + document.querySelector(`[data-type-list="${t}"]`).style.display, firstType + ); + if (hidden === 'none') pass(`type header click hides list (${firstType})`); + else fail(`type list display = "${hidden}" after click (expected "none")`); + + await page.click(`#alabSidebar .alab-type-hdr[data-type="${firstType}"]`); + const unhid = await page.evaluate((t) => + document.querySelector(`[data-type-list="${t}"]`).style.display, firstType + ); + if (unhid === '') pass(`second click restores list (${firstType})`); + else fail(`type list display = "${unhid}" after restore`); + + // (e) BPM slider + await page.evaluate(() => { + const s = document.getElementById('alabBPM'); + s.value = '90'; + s.dispatchEvent(new Event('input', { bubbles: true })); + }); + const bpmText = (await page.textContent('#alabBPMVal')).trim(); + if (bpmText === '90') pass(`alabBPMVal = ${bpmText}`); + else fail(`alabBPMVal = "${bpmText}" (expected 90)`); + + // (f) Volume slider + await page.evaluate(() => { + const s = document.getElementById('alabVol'); + s.value = '40'; + s.dispatchEvent(new Event('input', { bubbles: true })); + }); + const volText = (await page.textContent('#alabVolVal')).trim(); + // MeshAudio.setVolume() persists to localStorage regardless of whether + // AudioContext is initialized yet. getVolume() only reflects engine state + // after initAudio(), so we assert the persisted value as the cross-cut proof. + const volLs = await page.evaluate(() => parseFloat(localStorage.getItem('live-audio-volume'))); + if (volText === '40%' && Math.abs(volLs - 0.4) < 0.001) pass(`vol slider → text="${volText}" ls=${volLs}`); + else fail(`vol slider mismatch: text="${volText}" ls=${volLs}`); + + // (g) Speed buttons + const speedCount = await page.evaluate(() => document.querySelectorAll('.alab-speed').length); + if (speedCount >= 2) { + await page.evaluate(() => { + const btns = document.querySelectorAll('.alab-speed'); + btns[btns.length - 1].click(); + }); + const activeSpeed = await page.evaluate(() => { + const a = document.querySelectorAll('.alab-speed.active'); + return a.length; + }); + if (activeSpeed === 1) pass('speed button click → exactly one .alab-speed.active'); + else fail(`speed button click → ${activeSpeed} active`); + } else { + fail(`only ${speedCount} speed buttons found`); + } + + // (h) Loop button + await page.click('#alabLoop'); + const loopOn = await page.evaluate(() => document.getElementById('alabLoop').classList.contains('active')); + if (loopOn) pass('loop button toggled active'); + else fail('loop button did not toggle active'); + await page.click('#alabLoop'); // turn it off to stop any timer + const loopOff = await page.evaluate(() => document.getElementById('alabLoop').classList.contains('active')); + if (!loopOff) pass('loop button toggled off'); + else fail('loop button did not toggle off'); + + // (i) Play button triggers audio + const beforeOsc = await page.evaluate(() => window.__audioStub.oscillators); + await page.click('#alabPlay'); + // Give it a tick; voice.play schedules synchronously + await page.waitForTimeout(150); + const afterOsc = await page.evaluate(() => window.__audioStub.oscillators); + if (afterOsc > beforeOsc) pass(`play button triggered oscillators (${beforeOsc} → ${afterOsc})`); + else fail(`play button did not trigger oscillators (${beforeOsc} → ${afterOsc})`); + + // Click a note-row to exercise playOneNote + const hasNoteRow = await page.evaluate(() => !!document.querySelector('.alab-note-clickable')); + if (hasNoteRow) { + const before2 = await page.evaluate(() => window.__audioStub.oscillators); + await page.click('.alab-note-clickable'); + await page.waitForTimeout(100); + const after2 = await page.evaluate(() => window.__audioStub.oscillators); + if (after2 > before2) pass(`note-row click triggered playOneNote (${before2} → ${after2})`); + else fail(`note-row click did not trigger osc (${before2} → ${after2})`); + } + + // (j) destroy via navigation + await page.goto(`${BASE}/#/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(200); + const cleaned = await page.evaluate(() => ({ + hasSidebar: !!document.getElementById('alabSidebar'), + hasInjectedStyle: Array.from(document.querySelectorAll('style')).some(s => s.textContent && s.textContent.includes('.alab-sidebar')), + })); + if (!cleaned.hasSidebar) pass('destroy(): sidebar removed after navigation'); + else fail('destroy(): sidebar still present after navigation'); + if (!cleaned.hasInjectedStyle) pass('destroy(): style element removed'); + else fail('destroy(): style element still injected'); + + await browser.close(); + + console.log(`\n${passes} passed, ${failures} failed`); + process.exit(failures > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error('test-audio-lab-1297-e2e.js: ERROR', err); + process.exit(1); +}); diff --git a/test-audio-live-1297-e2e.js b/test-audio-live-1297-e2e.js new file mode 100644 index 00000000..dbcf0f0b --- /dev/null +++ b/test-audio-live-1297-e2e.js @@ -0,0 +1,262 @@ +#!/usr/bin/env node +/* Issue #1297 — B1 audio batch coverage. + * + * Exercises public/audio.js (MeshAudio engine) and + * public/audio-v1-constellation.js (voice module) via the /#/live page. + * + * Asserts: + * (a) MeshAudio global API surface is present + * (b) constellation voice is registered (window._meshAudioVoices) + * (c) #liveAudioToggle exists and #audioControls toggles visibility + * (d) BPM slider changes #audioBpmVal text + MeshAudio.getBPM() + * (e) Volume slider changes #audioVolVal text + MeshAudio.getVolume() + * (f) Voice select is populated with at least the constellation voice + * (g) MeshAudio helpers (buildScale, midiToFreq, mapRange, quantizeToScale) + * produce expected values + * (h) sonifyPacket() with a synthetic packet does not throw and exercises + * parsePacketBytes/voice.play paths (mocked AudioContext) + * (i) localStorage persistence for live-audio-enabled / bpm / volume + * + * Stable selectors: #liveAudioToggle, #audioControls, #audioBpmSlider, + * #audioBpmVal, #audioVolSlider, #audioVolVal, #audioVoiceSelect. + * + * CI gating: when CHROMIUM_REQUIRE=1 a missing/broken Chromium is HARD FAIL. + */ +'use strict'; + +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +async function main() { + const requireChromium = process.env.CHROMIUM_REQUIRE === '1'; + let browser; + try { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + } catch (err) { + if (requireChromium) { + console.error(`test-audio-live-1297-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`); + process.exit(1); + } + console.log(`test-audio-live-1297-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`); + process.exit(0); + } + + let failures = 0; + let passes = 0; + const fail = (msg) => { failures += 1; console.error(` FAIL: ${msg}`); }; + const pass = (msg) => { passes += 1; console.log(` PASS: ${msg}`); }; + + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + + // Stub AudioContext BEFORE app boots so sonifyPacket can run headlessly + // without real audio hardware. Capture invocations on window.__audioStub. + await page.addInitScript(() => { + window.__audioStub = { gainNodes: 0, oscillators: 0, contexts: 0 }; + function makeNode() { + return { + connect() { return makeNode(); }, + disconnect() {}, + start() {}, + stop() {}, + gain: { value: 0, setValueAtTime() {}, linearRampToValueAtTime() {}, exponentialRampToValueAtTime() {} }, + frequency: { value: 0, setValueAtTime() {}, linearRampToValueAtTime() {} }, + Q: { value: 0 }, + pan: { value: 0 }, + threshold: { value: 0 }, + knee: { value: 0 }, + ratio: { value: 0 }, + attack: { value: 0 }, + release: { value: 0 }, + type: 'sine', + }; + } + class FakeAudioContext { + constructor() { + window.__audioStub.contexts += 1; + this.state = 'running'; + this.currentTime = 0; + this.destination = makeNode(); + } + createGain() { window.__audioStub.gainNodes += 1; return makeNode(); } + createOscillator() { window.__audioStub.oscillators += 1; return makeNode(); } + createBiquadFilter() { return makeNode(); } + createDynamicsCompressor() { return makeNode(); } + createStereoPanner() { return makeNode(); } + createPanner() { return makeNode(); } + resume() { this.state = 'running'; return Promise.resolve(); } + suspend() { this.state = 'suspended'; return Promise.resolve(); } + } + window.AudioContext = FakeAudioContext; + window.webkitAudioContext = FakeAudioContext; + }); + + await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.MeshAudio && document.getElementById('liveAudioToggle')); + + // (a) MeshAudio API surface + const apiKeys = await page.evaluate(() => Object.keys(window.MeshAudio || {}).sort()); + const expectedKeys = ['getBPM', 'getContext', 'getVoiceName', 'getVoiceNames', + 'helpers', 'isEnabled', 'registerVoice', 'restore', 'setBPM', 'setEnabled', + 'setVoice', 'setVolume', 'getVolume', 'sonifyPacket'].sort(); + const missing = expectedKeys.filter(k => !apiKeys.includes(k)); + if (missing.length === 0) pass(`MeshAudio API has all expected methods (${apiKeys.length} keys)`); + else fail(`MeshAudio missing methods: ${missing.join(', ')}`); + + // (b) constellation voice registered + const voiceNames = await page.evaluate(() => Object.keys(window._meshAudioVoices || {})); + if (voiceNames.includes('constellation')) pass('constellation voice registered'); + else fail(`constellation voice not registered (have: ${voiceNames.join(', ') || 'none'})`); + + // (c) Toggle reveals audio controls + const initiallyHidden = await page.evaluate(() => document.getElementById('audioControls').classList.contains('hidden')); + if (initiallyHidden) pass('audioControls hidden by default'); + else fail('audioControls should be hidden by default'); + + await page.evaluate(() => { + const t = document.getElementById('liveAudioToggle'); + t.checked = true; + t.dispatchEvent(new Event('change', { bubbles: true })); + }); + const shown = await page.evaluate(() => !document.getElementById('audioControls').classList.contains('hidden')); + if (shown) pass('audioControls revealed after toggle on'); + else fail('audioControls did not unhide after toggle'); + + const engineEnabled = await page.evaluate(() => window.MeshAudio.isEnabled()); + if (engineEnabled) pass('MeshAudio.isEnabled() true after toggle'); + else fail('MeshAudio.isEnabled() false after toggle'); + + // (d) BPM slider + await page.evaluate(() => { + const s = document.getElementById('audioBpmSlider'); + s.value = '180'; + s.dispatchEvent(new Event('input', { bubbles: true })); + }); + const bpmText = await page.textContent('#audioBpmVal'); + const bpmEngine = await page.evaluate(() => window.MeshAudio.getBPM()); + if (bpmText.trim() === '180' && bpmEngine === 180) pass(`BPM slider → text=${bpmText} engine=${bpmEngine}`); + else fail(`BPM slider mismatch: text=${bpmText} engine=${bpmEngine}`); + + // (e) Volume slider + await page.evaluate(() => { + const s = document.getElementById('audioVolSlider'); + s.value = '55'; + s.dispatchEvent(new Event('input', { bubbles: true })); + }); + const volText = await page.textContent('#audioVolVal'); + const volEngine = await page.evaluate(() => Math.round(window.MeshAudio.getVolume() * 100)); + if (volText.trim() === '55' && volEngine === 55) pass(`Volume slider → text=${volText} engine=${volEngine}`); + else fail(`Volume slider mismatch: text=${volText} engine=${volEngine}`); + + // (f) Voice select populated + const voiceOptionCount = await page.evaluate(() => + document.querySelectorAll('#audioVoiceSelect option').length + ); + if (voiceOptionCount >= 1) pass(`voice select has ${voiceOptionCount} option(s)`); + else fail('voice select empty'); + + // (g) Helpers + const helperResults = await page.evaluate(() => { + const h = window.MeshAudio.helpers; + return { + scale: h.buildScale([0, 4, 7], 60), // major triad-ish, 3 octaves * 3 notes = 9 notes + freq60: h.midiToFreq(60), // middle C ~ 261.625 + freq69: h.midiToFreq(69), // A4 = 440 + map: h.mapRange(5, 0, 10, 0, 100), // 50 + quant: h.quantizeToScale(128, [0, 1, 2, 3]), // ~2 + }; + }); + if (helperResults.scale.length === 9) pass(`buildScale → 9 notes`); + else fail(`buildScale length=${helperResults.scale.length} (want 9)`); + if (Math.abs(helperResults.freq69 - 440) < 0.01) pass('midiToFreq(69) ≈ 440Hz'); + else fail(`midiToFreq(69) = ${helperResults.freq69}`); + if (Math.abs(helperResults.map - 50) < 0.01) pass('mapRange linear OK'); + else fail(`mapRange = ${helperResults.map}`); + if (helperResults.quant === 2) pass('quantizeToScale OK'); + else fail(`quantizeToScale = ${helperResults.quant}`); + + // (h) sonifyPacket with synthetic packet — exercises parsePacketBytes + // + voice.play. Headers + ~20 payload bytes. + const sonifyResult = await page.evaluate(() => { + const beforeOsc = window.__audioStub.oscillators; + const beforeGain = window.__audioStub.gainNodes; + let threw = null; + try { + const pkt = { + raw: 'a1b2c3' + '00112233445566778899aabbccddeeff' + '1020', + observation_count: 3, + decoded: { + header: { payloadTypeName: 'ADVERT' }, + payload: { lat: 47.6, lon: -122.3 }, + path: { hops: [{ hop: 'aa' }, { hop: 'bb' }] }, + }, + }; + window.MeshAudio.sonifyPacket(pkt); + } catch (e) { threw = e.message; } + return { + threw, + deltaOsc: window.__audioStub.oscillators - beforeOsc, + deltaGain: window.__audioStub.gainNodes - beforeGain, + }; + }); + if (!sonifyResult.threw && sonifyResult.deltaOsc > 0) { + pass(`sonifyPacket exercised voice (oscillators +${sonifyResult.deltaOsc}, gains +${sonifyResult.deltaGain})`); + } else { + fail(`sonifyPacket: threw=${sonifyResult.threw} oscΔ=${sonifyResult.deltaOsc}`); + } + + // Try a second packet with a different type to cover more SCALES/SYNTHS branches + const sonify2 = await page.evaluate(() => { + let threw = null; + try { + ['GRP_TXT', 'TXT_MSG', 'TRACE', 'UNKNOWN'].forEach(t => { + window.MeshAudio.sonifyPacket({ + raw: '010203' + 'ff'.repeat(16), + observation_count: 1, + decoded: { header: { payloadTypeName: t }, payload: {}, path: { hops: [] } }, + }); + }); + } catch (e) { threw = e.message; } + return threw; + }); + if (!sonify2) pass('sonifyPacket multi-type OK'); + else fail(`sonifyPacket multi-type threw: ${sonify2}`); + + // (i) localStorage persistence + const ls = await page.evaluate(() => ({ + enabled: localStorage.getItem('live-audio-enabled'), + bpm: localStorage.getItem('live-audio-bpm'), + vol: localStorage.getItem('live-audio-volume'), + })); + if (ls.enabled === 'true' && ls.bpm === '180' && parseFloat(ls.vol) > 0.5) { + pass(`localStorage persisted: enabled=${ls.enabled} bpm=${ls.bpm} vol=${ls.vol}`); + } else { + fail(`localStorage persistence: ${JSON.stringify(ls)}`); + } + + // Toggle OFF should re-hide + await page.evaluate(() => { + const t = document.getElementById('liveAudioToggle'); + t.checked = false; + t.dispatchEvent(new Event('change', { bubbles: true })); + }); + const reHidden = await page.evaluate(() => document.getElementById('audioControls').classList.contains('hidden')); + if (reHidden) pass('audioControls re-hides after toggle off'); + else fail('audioControls did not re-hide after toggle off'); + + await browser.close(); + + console.log(`\n${passes} passed, ${failures} failed`); + process.exit(failures > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error('test-audio-live-1297-e2e.js: ERROR', err); + process.exit(1); +});