Confirmed the brightness command applies globally, not per-LED
(serverinfo showed exactly one adjustment object, "id": "default",
covering the whole string), so no LED-count configuration is needed
for this at all -- that question resolved itself once the mechanism
was actually inspected instead of assumed.
For "only react while Spotify is running": only one app can be in the
foreground on webOS at a time, so a "capture the current app" button
in this app's own UI can never work -- pressing it means this app is
foreground, not Spotify. The only workable UI is picking a target from
every *installed* app by name, regardless of what's currently running.
That needed a new native capability this service never had: calling
OUT to another Luna service, not just being called. Two additions:
foreground_app.c subscribes once, at startup, to
com.webos.applicationManager/getForegroundAppInfo
and keeps a thread-safe cache the audio thread can
read without a blocking Luna call
service.c new listApps method, bridging to
com.webos.applicationManager/listApps so the
frontend never has to call another service
directly -- same rule as everywhere else here
Until the subscription has delivered at least one reply, a restricted
sink treats the target app as inactive, not active -- reacting to
audio when the user explicitly restricted it to one app would be the
wrong failure mode. Verified end to end on the host: engine_smoke.c
opens the sink with a restriction set, confirms it reports itself
correctly inactive against the stub Luna bus (which always "fails" to
call out, exactly like a real host with no bus).
Needed real, linkable stub bodies for LSCall/LSCallOneReply/
LSCallCancel/LSMessageGetPayload/LSErrorInit/LSErrorFree
(test/stubs/luna-service2/lunaservice_stub.c) since foreground_app.c
is the first source file here that's actually linked into a host test
binary rather than only syntax-checked -- service.c/main.c's existing
stub declarations were never called, only compiled against. Confirmed
those really are the correct symbol names by cross-compiling clean
against the real webOS SDK's actual libluna-service2, not just the
stub.
Bumped to 1.0.5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
11 KiB
JavaScript
267 lines
11 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"]'));
|
|
const restrictPicker = doc.querySelector('[data-path="hyperhdrAdjust.restrictToApp"]');
|
|
check('restrict-to-app picker exists', !!restrictPicker);
|
|
eq('restrict-to-app picker starts on Always active', restrictPicker.textContent, 'Always active');
|
|
// Always active -> the first installed app alphabetically by title
|
|
// ("Live TV", ahead of Netflix/Spotify/YouTube in the mock's list).
|
|
click(restrictPicker);
|
|
await wait(600);
|
|
eq('picking an app reaches settings by id, not a typed value',
|
|
window.App.state.settings.hyperhdrAdjust.restrictToApp, 'com.webos.app.livetv');
|
|
eq('picker now shows the app name, not the id', restrictPicker.textContent, 'Live TV');
|
|
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);
|
|
});
|