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