Files
lgtv_audio_cap/test/ui_smoke.js
T
Rene KievitsandClaude Opus 5 aae5a33283 Add a brightness-only sink: keep the grabber's colour, pulse with sound
Every existing HyperHDR route replaces whatever else is on the LEDs:
routes 1/2 hand HyperHDR's own audio effect a device to read, route 3
sends a synthetic spectrum image, and both take over via HyperHDR's
priority system. For a setup that already has a real colour source
(a screen grabber, a USB capture card) feeding an ambilight-style LED
run, none of that is what's wanted -- the colour should stay put and
only brightness should react.

Read HyperHDR's own source (sources/api/JSONRPC_schema/schema-adjustment.json)
rather than guess: "adjustment" is a post-processing command with a
scaleOutput parameter (0-2.0) that applies regardless of which
priority is currently active. Confirmed the wire format too --
sources/jsonserver/JsonClientConnection.cpp frames it as plain
newline-delimited JSON over TCP (default port 19444), nothing like the
length-prefixed Flatbuffers protocol the visualiser sink speaks, and
with no handshake or registration needed before the first write.

sink_hyperhdr_adjust.c sends only that: no image, no priority, so it
never competes with an existing grabber. Non-blocking connect with the
same poll()+SO_ERROR pattern net/hyperion.c already uses, reconnects
every 5s, rate-limited to 20 Hz (a HyperHDR command every audio block
would be pointless flooding), and resets scaleOutput to 1.0 on close
rather than leaving the LEDs stuck at whatever it last sent. Cross-
compiles clean under -Wall -Wextra on the real webOS toolchain.

Wired through the same path every other sink follows: registered in
sink.c/sink.h, defaults in config.c, fields in frontend/js/app.js
(SINK_FIELDS/SINK_HELP), mock.js and ui_smoke.js updated for the new
sink card. Bumped to 1.0.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:36:31 +02:00

257 lines
10 KiB
JavaScript

// 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);
// This loads the raw source tree's index.html, not a packaged build, so
// the __APP_VERSION__ placeholder was never substituted — the fallback
// is the correct, honest thing to see here.
eq('unpackaged run shows the dev-build fallback, not a stale version',
window.App.state.appVersion, 'dev build');
check('app version shown in the System panel',
$('config-path').textContent.indexOf('dev build') >= 0);
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('six sink cards', doc.querySelectorAll('.sink-card').length, 6);
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');
// Diagnostics run once automatically at boot, so the picker is already
// there — the user should not have to press the button first.
check('device picker already present from the boot-time diagnostics run',
!!doc.querySelector('[data-path="capture.device"].choice'));
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('device picker');
const picker = doc.querySelector('[data-path="capture.device"].choice');
check('device picker appears once sources are known', !!picker);
eq('picker starts on Automatic', picker.textContent, 'Automatic (@DEFAULT_MONITOR@)');
click(picker); // Automatic -> tpcm_output.monitor
await wait(600);
eq('picking a source reaches settings',
window.App.state.settings.capture.device, 'tpcm_output.monitor');
eq('the plain device field reflects the pick',
doc.querySelector('input[data-path="capture.device"]').value, 'tpcm_output.monitor');
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);
});