Diagnosing capture on a real TV meant reading pactlSources off the screen and typing an exact PulseAudio source name back in through the same remote-driven text field — no way to copy-paste, easy to mistype, and the one piece of information (which source, if any, is actually RUNNING) was buried in a JSON dump. Added two choice() pickers bound to the same capture.device setting: one built from pactlSources (pulse/auto backends), one built from alsaCapturePcms (alsa backend), both parsed from diagnostics the service already collects — no new Luna method needed. Diagnostics already run once at boot, so the picker is populated immediately, before the user ever presses "Run diagnostics" by hand. Picking a value writes straight into capture.device, and the plain text field stays as the fallback for anything the parser misses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
250 lines
9.6 KiB
JavaScript
250 lines
9.6 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);
|
|
|
|
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');
|
|
// 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);
|
|
});
|