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