Audio capture and streaming app for webOS 5/6
Captures the TV's audio and sends it out over several transports. The
primary one is HyperHDR: RTP/L16 to a host-side loopback device, since
HyperHDR has no network audio input of its own. A second route renders
the spectrum on the TV and sends FlatBuffers images to port 19400
instead, for setups where touching the host's sound config is not an
option.
native/ the service: capture backends (PulseAudio, ALSA, exec,
test tone, all dlopen-based), DSP, and one file per sink
frontend/ D-pad driven UI at a fixed 1920x1080
servicefiles/ native service manifest plus the boot script
host/ RTP receiver and the loopback installer for the HyperHDR
machine
tools/ build/package, asset generation, Homebrew Channel
manifest, on-TV probe
test/ host-side suites: FlatBuffers and RTP verified against
real decoders, the engine end to end, the page in jsdom
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
// Loads the real index.html in jsdom, against the browser mock of the Luna
|
||||
// bus, and drives it the way a remote would. Catches the mistakes that only
|
||||
// show up when the page actually runs: a typo'd element id, a control wired to
|
||||
// a setting that does not exist, a render that throws on the first status
|
||||
// frame.
|
||||
//
|
||||
// jsdom is not vendored. Install it anywhere and point NODE_PATH at it:
|
||||
// mkdir -p /tmp/audiocap-domtest && cd /tmp/audiocap-domtest && npm i jsdom
|
||||
// NODE_PATH=/tmp/audiocap-domtest/node_modules node test/ui_smoke.js
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const PAGE = path.join(ROOT, 'frontend', 'index.html');
|
||||
|
||||
let checks = 0;
|
||||
let failures = 0;
|
||||
|
||||
function check(name, condition, detail) {
|
||||
checks++;
|
||||
if (condition) {
|
||||
console.log(' ok ' + name);
|
||||
} else {
|
||||
failures++;
|
||||
console.log(' FAIL ' + name + (detail === undefined ? '' : ' — ' + detail));
|
||||
}
|
||||
}
|
||||
|
||||
function eq(name, actual, expected) {
|
||||
check(name, actual === expected, 'got ' + JSON.stringify(actual)
|
||||
+ ', wanted ' + JSON.stringify(expected));
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// jsdom has no layout, so every rect is zero and the geometric navigator has
|
||||
// nothing to work with. Fake a plausible screen: the header button top right,
|
||||
// the tabs in a row, every other control stacked down the page.
|
||||
function fakeLayout(window) {
|
||||
const rects = new WeakMap();
|
||||
const focusables = window.document.querySelectorAll('.focusable');
|
||||
let row = 0;
|
||||
|
||||
focusables.forEach((el) => {
|
||||
let rect;
|
||||
if (el.id === 'power') {
|
||||
rect = { left: 1600, top: 40, width: 200, height: 60 };
|
||||
} else if (el.classList.contains('tab')) {
|
||||
const index = Array.prototype.indexOf.call(
|
||||
window.document.querySelectorAll('.tab'), el);
|
||||
rect = { left: 60 + index * 220, top: 160, width: 200, height: 60 };
|
||||
} else {
|
||||
rect = { left: 1200, top: 280 + row * 90, width: 360, height: 60 };
|
||||
row++;
|
||||
}
|
||||
rect.right = rect.left + rect.width;
|
||||
rect.bottom = rect.top + rect.height;
|
||||
rects.set(el, rect);
|
||||
});
|
||||
|
||||
window.Element.prototype.getBoundingClientRect = function () {
|
||||
return rects.get(this) || { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
function press(window, keyCode) {
|
||||
const event = new window.KeyboardEvent('keydown', {
|
||||
keyCode: keyCode, bubbles: true, cancelable: true,
|
||||
});
|
||||
// jsdom's KeyboardEvent ignores the legacy keyCode field.
|
||||
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
|
||||
window.document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function click(el) {
|
||||
el.dispatchEvent(new el.ownerDocument.defaultView.MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const errors = [];
|
||||
const virtualConsole = new VirtualConsole();
|
||||
virtualConsole.on('jsdomError', (e) => errors.push(String(e && e.message || e)));
|
||||
virtualConsole.on('error', (...args) => errors.push(args.join(' ')));
|
||||
|
||||
const dom = await JSDOM.fromFile(PAGE, {
|
||||
runScripts: 'dangerously',
|
||||
resources: 'usable',
|
||||
pretendToBeVisual: true,
|
||||
virtualConsole,
|
||||
});
|
||||
const window = dom.window;
|
||||
window.addEventListener('error', (e) => errors.push(String(e.message)));
|
||||
|
||||
await new Promise((resolve) => {
|
||||
if (window.document.readyState === 'complete') {
|
||||
resolve();
|
||||
} else {
|
||||
window.addEventListener('load', resolve);
|
||||
}
|
||||
});
|
||||
// The mock answers after 30 ms; give the whole load sequence room.
|
||||
await wait(250);
|
||||
|
||||
const doc = window.document;
|
||||
const $ = (id) => doc.getElementById(id);
|
||||
|
||||
console.log('page load');
|
||||
check('no script errors', errors.length === 0, errors.join(' | '));
|
||||
check('mock bus in use', window.Luna.available === false);
|
||||
check('settings loaded', !!(window.App.state.settings.capture));
|
||||
eq('config path shown', $('config-path').textContent.indexOf('/var/lib/webosbrew') >= 0, true);
|
||||
|
||||
console.log('status feed');
|
||||
eq('starts stopped', $('state-pill').textContent, 'Stopped');
|
||||
eq('power button offers start', $('power').textContent, 'Start');
|
||||
eq('sixteen band bars', doc.querySelectorAll('.band').length, 16);
|
||||
eq('no sinks listed while stopped', $('sink-status').textContent.trim(), 'Not running.');
|
||||
|
||||
console.log('panels');
|
||||
eq('five sink cards', doc.querySelectorAll('.sink-card').length, 5);
|
||||
check('hyperhdr card is first and marked',
|
||||
doc.querySelector('.sink-card .badge').textContent === 'Recommended');
|
||||
check('hyperhdr host field exists', !!doc.querySelector('[data-path="hyperhdr.host"]'));
|
||||
check('backend choice exists', !!doc.querySelector('[data-path="capture.backend"]'));
|
||||
check('log level choice exists', !!doc.querySelector('[data-path="logLevel"]'));
|
||||
check('boot toggle exists', !!doc.querySelector('[data-path="autoStart"]'));
|
||||
// The mock reports the service already running as root.
|
||||
eq('root state reflected',
|
||||
doc.querySelector('[data-path="elevate"]').textContent, 'Re-apply');
|
||||
// Conditional fields: multicast is off by default, so its TTL stays hidden.
|
||||
check('multicast ttl hidden while multicast is off',
|
||||
!doc.querySelector('[data-path="hyperhdr.multicastTtl"]'));
|
||||
// exec-only fields stay out of the way of the default pulse/alsa setup.
|
||||
check('command field hidden for automatic backend',
|
||||
!doc.querySelector('[data-path="capture.command"]'));
|
||||
|
||||
console.log('editing');
|
||||
const host = doc.querySelector('[data-path="hyperhdr.host"]');
|
||||
host.value = '10.0.0.9';
|
||||
host.dispatchEvent(new window.Event('change'));
|
||||
await wait(600);
|
||||
eq('host edit reached the service', window.App.state.settings.hyperhdr.host, '10.0.0.9');
|
||||
|
||||
const multicast = doc.querySelector('[data-path="hyperhdr.multicast"]');
|
||||
click(multicast);
|
||||
await wait(600);
|
||||
eq('multicast toggled', window.App.state.settings.hyperhdr.multicast, true);
|
||||
check('multicast ttl appears once enabled',
|
||||
!!doc.querySelector('[data-path="hyperhdr.multicastTtl"]'));
|
||||
|
||||
const backend = doc.querySelector('[data-path="capture.backend"]');
|
||||
click(backend); // auto -> pulse
|
||||
await wait(600);
|
||||
eq('backend cycled', window.App.state.settings.capture.backend, 'pulse');
|
||||
check('server field appears for pulse',
|
||||
!!doc.querySelector('[data-path="capture.server"]'));
|
||||
check('command field still hidden for pulse',
|
||||
!doc.querySelector('[data-path="capture.command"]'));
|
||||
|
||||
const udpToggle = doc.querySelector('[data-sink="udp"]');
|
||||
click(udpToggle);
|
||||
await wait(600);
|
||||
check('udp sink enabled',
|
||||
window.App.state.settings.sinks.indexOf('udp') >= 0,
|
||||
JSON.stringify(window.App.state.settings.sinks));
|
||||
|
||||
console.log('running');
|
||||
click($('power'));
|
||||
await wait(300);
|
||||
eq('pill reports running', $('state-pill').textContent, 'Running');
|
||||
eq('power button offers stop', $('power').textContent, 'Stop');
|
||||
check('sinks listed while running',
|
||||
doc.querySelectorAll('.sink-line').length >= 2,
|
||||
doc.querySelectorAll('.sink-line').length + ' lines');
|
||||
check('meter moved', parseFloat($('meter-peak').firstChild.style.width) > 0,
|
||||
$('meter-peak').firstChild.style.width);
|
||||
const tallest = Array.prototype.reduce.call(doc.querySelectorAll('.band'),
|
||||
(max, b) => Math.max(max, parseFloat(b.style.height) || 0), 0);
|
||||
check('bands moved', tallest > 3, tallest + 'px');
|
||||
check('capture info filled',
|
||||
$('capture-info').textContent.indexOf('48000 Hz') >= 0,
|
||||
$('capture-info').textContent);
|
||||
|
||||
click($('power'));
|
||||
await wait(300);
|
||||
eq('stops again', $('state-pill').textContent, 'Stopped');
|
||||
|
||||
console.log('diagnostics');
|
||||
click($('run-diagnostics'));
|
||||
await wait(200);
|
||||
check('diagnostics output shown',
|
||||
!$('output').classList.contains('hidden')
|
||||
&& $('output').textContent.indexOf('libpulse') >= 0);
|
||||
click($('load-logs'));
|
||||
await wait(200);
|
||||
check('log output shown', $('output').textContent.indexOf('browser mock') >= 0);
|
||||
|
||||
console.log('navigation');
|
||||
fakeLayout(window);
|
||||
const tabs = doc.querySelectorAll('.tab');
|
||||
tabs[0].focus();
|
||||
press(window, 39);
|
||||
eq('right moves along the tab row', doc.activeElement, tabs[1]);
|
||||
press(window, 37);
|
||||
eq('left comes back', doc.activeElement, tabs[0]);
|
||||
press(window, 38);
|
||||
eq('up reaches the header button', doc.activeElement, $('power'));
|
||||
press(window, 40);
|
||||
check('down leaves the header', doc.activeElement !== $('power'));
|
||||
|
||||
console.log('tabs');
|
||||
click(tabs[1]);
|
||||
check('outputs panel shown', !$('panel-sinks').classList.contains('hidden'));
|
||||
check('status panel hidden', $('panel-status').classList.contains('hidden'));
|
||||
press(window, 461); // Back
|
||||
check('back returns to status', !$('panel-status').classList.contains('hidden'));
|
||||
|
||||
check('still no script errors', errors.length === 0, errors.join(' | '));
|
||||
|
||||
window.close();
|
||||
|
||||
console.log('\n' + (checks - failures) + '/' + checks + ' checks passed');
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user