Files
meshcore-analyzer/test-audio-lab-1297-e2e.js
T
Kpa-clawbot c24ae4b617 test(coverage): add Playwright E2E for audio batch (#1297 B1) (#1299)
## Summary

Adds Playwright E2E coverage for the **B1 audio batch** per umbrella
issue #1297.
Targets the audio frontend trio that previously had near-zero
browser-side
coverage: `public/audio.js`, `public/audio-v1-constellation.js`,
`public/audio-lab.js` (562 LOC, 4.2% prior coverage).

## What's added

| Suite | Covers | Scenarios |
|---|---|---|
| `test-audio-live-1297-e2e.js` | `audio.js` +
`audio-v1-constellation.js` via `/#/live` | 16 |
| `test-audio-lab-1297-e2e.js` | `audio-lab.js` via `/#/audio-lab` | 15
|

Both suites stub `AudioContext` via `page.addInitScript` so headless
Chromium
can verify oscillator scheduling / voice playback paths without real
audio
hardware — covers the `voice.play()` ADSR chain for
ADVERT/GRP_TXT/TXT_MSG/TRACE
and the `UNKNOWN`/default branches.

### `test-audio-live-1297-e2e.js`
- MeshAudio API surface (14 keys)
- `constellation` voice auto-registration
- `#liveAudioToggle` ↔ `#audioControls` show/hide round trip
- BPM slider → `#audioBpmVal` text + `MeshAudio.getBPM()` + localStorage
- Volume slider → `#audioVolVal` + `MeshAudio.getVolume()` +
localStorage
- Voice select population
- Helpers: `buildScale`, `midiToFreq(69)≈440`, `mapRange`,
`quantizeToScale`
- `sonifyPacket()` exercises `parsePacketBytes` + `voice.play` (asserts
  oscillator count increments) across 5 packet types
- localStorage persistence for `live-audio-enabled` / `bpm` / `volume`

### `test-audio-lab-1297-e2e.js`
- `/api/audio-lab/buckets` is intercepted with deterministic fixture
data
(3 packet types, 4 packets) so coverage doesn't depend on CI's packet
mix
- Sidebar populated, packet selection (`.alab-pkt.selected`)
- `renderDetail` + `computeMapping`: hex panel, note table (≥2 rows),
  byte viz bars (≥3 bars), map table
- Type header click toggles list `display:none` ↔ visible
- BPM / Vol slider handlers
- Speed buttons (active class swap)
- Loop button toggle on/off
- Play button → `MeshAudio.sonifyPacket` (oscillator count↑)
- Note-row click → `playOneNote` (oscillator count↑)
- `destroy()` removes sidebar + injected stylesheet on navigation away

## Coverage estimate (per-file)

Measured locally (assertion counts, not nyc — that runs in CI):

| File | Before | After (estimated) | Notes |
|---|---|---|---|
| `public/audio.js` | ~low | **≥70%** | All public API methods + helpers
+ sonifyPacket path exercised |
| `public/audio-v1-constellation.js` | ~0% | **≥60%** | `play()` invoked
across 5 type branches |
| `public/audio-lab.js` | 4.2% | **≥55%** | `init`, `renderDetail`,
`computeMapping`, `playOneNote`, `playSelected`, `destroy`, all
slider/button handlers |

Actual coverage will be confirmed by the `Generate frontend coverage
badges`
step in CI on this PR.

## TDD exemption

These are **net-new UI coverage** suites — there are no prior assertions
to break, and no production behavior is changing. Per `AGENTS.md` TDD
rules:

> Net-new UI surfaces (no prior assertions to break): test must land in
the
> SAME PR but doesn't need to be the FIRST commit.

Single commit; no red→green choreography possible because the assertions
exercise already-shipped behavior. Suites are designed to FAIL loudly if
the audio engine or audio-lab page regresses (e.g. if `#audioBpmVal`
stops
updating, or `voice.play` stops scheduling oscillators).

## Workflow hookup

Appended to the existing `playwright-tests` step in
`.github/workflows/deploy.yml`:

```yaml
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-live-1297-e2e.js ...
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-lab-1297-e2e.js ...
```

Both run with `CHROMIUM_REQUIRE=1` — missing Chromium is a hard fail in
CI
(per the project convention shared with `test-bottom-nav-1061-e2e.js` et
al).

## Local verification

```
16 passed, 0 failed   (test-audio-live-1297-e2e.js)
15 passed, 0 failed   (test-audio-lab-1297-e2e.js)
```

Run against a local `/tmp/cov-b1-server -port 13591 -db <fixture>`
instance
with `test-fixtures/e2e-fixture.db`.

Refs #1297

Co-authored-by: clawbot <bot@kpa-clawbot>
2026-05-19 23:53:44 -07:00

269 lines
12 KiB
JavaScript

#!/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);
});