mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
Both shipped in #538 with no interface at all: the bridge could only be configured by editing integrations/mqtt_bridge/bridge_config.json over SSH, and the Pixlet editor was a script you had to know existed. Neither appeared anywhere in the web UI. They are now two sections in the Tools tab. **MQTT bridge.** Service state (installed / running / stopped), install, start, stop and restart, and a form for every setting: broker host and port, username, TLS, command topic, client id, API base, request timeout, on-demand duration and log level. The password is write-only. GET returns every other field plus a password_set flag, never the value -- this interface has no authentication, so anything it hands back is readable by anyone who can reach the port. Submitting a blank password keeps whatever is stored; there is an explicit Clear for removing one. The file is written through a temp file in the same directory and chmod 0600, so a crash mid-write cannot leave a config the bridge would refuse to load. Values are range-checked server-side (port, timeouts, log level, http(s) API base). **Pixlet editor.** Lists the apps on disk and starts a session per app, with a banner showing which app is open, a link to the editor, a countdown, and Stop. The banner survives a page reload because status is polled while a session runs, and Edit is disabled while one is open. Two changes to the script make that safe to drive from a browser: - It binds 0.0.0.0 by default (PIXLET_EDITOR_HOST overrides). Loopback-only meant the URL shown in a browser was unreachable and needed an SSH tunnel, which is the confusing path. The no-auth argument for loopback does not hold when web_interface already serves 0.0.0.0:5000 unauthenticated and can reconfigure everything. - The session ends by itself after PIXLET_EDITOR_TIMEOUT (default 30 min), enforced by `timeout` inside the script rather than by the caller. The display is stopped while editing, so a forgotten session would otherwise leave the panel dark and read as broken hardware -- the timeout is the whole reason a Start button is defensible. Two bugs found while testing that lifecycle, both of which would have bitten the Stop button: - GNU timeout runs its child in a NEW process group, so signalling the script's group killed only bash and orphaned pixlet with the port still bound. Now --foreground keeps one group, and the EXIT trap kills the recorded child too. - The liveness check treated a zombie as running: the script is a child of the web process and stays unreaped after exiting, and signal 0 succeeds against a zombie, so a finished session would have read as running forever. It now reaps non-blockingly and falls back to /proc state for a process that is not ours. Covered by test/js/dom/test_tools_sections.js (26 assertions, real DOM against the server-rendered partial): form prefill, the blank-means-unchanged password contract, the running-session banner and countdown, and that the editor link uses the host the page was loaded from. Also chmod +x on both scripts, which the docs tell you to run directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
117 lines
6.4 KiB
JavaScript
117 lines
6.4 KiB
JavaScript
// Real-DOM (jsdom) test of the two new Tools sections. The HTML is the actual
|
|
// server-rendered /partials/tools, and the payloads are the real API's, so a
|
|
// renamed field or a changed shape fails this rather than passing quietly.
|
|
const http = require('http');
|
|
const { JSDOM, VirtualConsole } = require('jsdom');
|
|
|
|
const BASE = process.env.BASE || 'http://localhost:5000';
|
|
const get = p => new Promise((res, rej) =>
|
|
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
|
|
|
(async () => {
|
|
const partial = await get('/partials/tools');
|
|
const bridge = JSON.parse(await get('/api/v3/integrations/mqtt-bridge'));
|
|
const apps = JSON.parse(await get('/api/v3/starlark/editor/apps'));
|
|
|
|
const errs = [];
|
|
const vc = new VirtualConsole();
|
|
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
|
|
|
// Controllable fetch: serve the real payloads, and let tests swap in others.
|
|
let editorStatus = { status: 'success', data: { running: false } };
|
|
let bridgePayload = bridge;
|
|
let onPut = null;
|
|
const stubFetch = (url, opts) => {
|
|
const u = String(url);
|
|
if (onPut && opts && opts.method === 'PUT') onPut(JSON.parse(opts.body));
|
|
let body = { status: 'success', data: {} };
|
|
if (u.includes('/integrations/mqtt-bridge')) body = bridgePayload;
|
|
else if (u.includes('/starlark/editor/status')) body = editorStatus;
|
|
else if (u.includes('/starlark/editor/apps')) body = apps;
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
|
};
|
|
|
|
// runScripts:'dangerously' so the partial's own <script> executes the way a
|
|
// browser runs it -- function declarations land on window. Evaluating the
|
|
// source by hand instead leaves helpers like escHtml off the global object
|
|
// and the page fails in ways it never would in a browser.
|
|
const dom = new JSDOM(`<!doctype html><html><body>${partial}</body></html>`,
|
|
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/',
|
|
beforeParse(w) { w.fetch = stubFetch; w.confirm = () => true; } });
|
|
const { window } = dom;
|
|
|
|
const tick = ms => new Promise(r => setTimeout(r, ms));
|
|
await tick(300);
|
|
|
|
const $ = id => window.document.getElementById(id);
|
|
let pass = 0, fail = 0;
|
|
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
|
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 200) : '')));
|
|
|
|
console.log('\n── Tools: MQTT bridge + Pixlet editor (real DOM) ──');
|
|
|
|
// ── MQTT bridge ────────────────────────────────────────────────────────
|
|
ok('bridge form rendered', !!$('mqtt-host'), $('mqtt-bridge-body').textContent.slice(0, 80));
|
|
ok('host prefilled from the API', $('mqtt-host').value === bridge.data.config.mqtt_host,
|
|
{ got: $('mqtt-host') && $('mqtt-host').value, want: bridge.data.config.mqtt_host });
|
|
ok('port prefilled', $('mqtt-port').value === String(bridge.data.config.mqtt_port));
|
|
ok('log level selected', $('mqtt-log-level').value === bridge.data.config.log_level);
|
|
ok('TLS checkbox matches', $('mqtt-tls').checked === !!bridge.data.config.mqtt_tls);
|
|
ok('password field is EMPTY', $('mqtt-password').value === '');
|
|
ok('password field is type=password', $('mqtt-password').type === 'password');
|
|
ok('no password value anywhere in the DOM',
|
|
!/s3cret|mqtt_password"\s*:\s*"/.test(window.document.body.innerHTML));
|
|
ok('state badge rendered', ($('mqtt-bridge-state').textContent || '').trim().length > 0,
|
|
$('mqtt-bridge-state').textContent);
|
|
ok('not-installed shows an Install button', !!$('btn-mqtt-install'));
|
|
ok('config path shown', $('mqtt-bridge-body').textContent.includes('bridge_config.json'));
|
|
ok('env override hint shown', $('mqtt-bridge-body').textContent.includes('LEDMATRIX_MQTT_'));
|
|
|
|
// The save body must omit the password when the field is blank.
|
|
let sent = null;
|
|
onPut = body => { sent = body; };
|
|
window.saveMqttBridge();
|
|
await tick(150);
|
|
ok('save omits password when left blank', sent && !('mqtt_password' in sent), sent && Object.keys(sent));
|
|
ok('save sends the edited fields', sent && sent.mqtt_host === bridge.data.config.mqtt_host, sent);
|
|
|
|
$('mqtt-password').value = 'typed-secret';
|
|
window.saveMqttBridge();
|
|
await tick(150);
|
|
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
|
|
onPut = null;
|
|
|
|
// ── Pixlet editor, idle ────────────────────────────────────────────────
|
|
const appIds = (apps.data.apps || []).map(a => a.id);
|
|
ok('editor lists the apps on disk',
|
|
appIds.every(id => $('pixlet-editor-body').textContent.includes(id)), appIds);
|
|
ok('no session banner while idle', !$('pixlet-countdown'));
|
|
ok('warns that the display stops',
|
|
window.document.body.textContent.includes('display stops while a session is open'));
|
|
|
|
// ── Pixlet editor, running ─────────────────────────────────────────────
|
|
editorStatus = { status: 'success', data: {
|
|
running: true, app_id: 'test-editor-app', port: 8099, seconds_remaining: 1634,
|
|
timeout: 1800, host_bound: '0.0.0.0' } };
|
|
window.loadPixletEditor();
|
|
await tick(200);
|
|
|
|
ok('running session shows the banner', !!$('pixlet-countdown'));
|
|
ok('countdown formatted mm:ss', $('pixlet-countdown').textContent === '27:14',
|
|
$('pixlet-countdown') && $('pixlet-countdown').textContent);
|
|
ok('Stop button offered', !!$('btn-pixlet-stop'));
|
|
const link = [...window.document.querySelectorAll('#pixlet-editor-body a')].find(a => /Open the editor/.test(a.textContent));
|
|
ok('editor link present', !!link);
|
|
ok('link uses this host, not localhost — no tunnel needed',
|
|
!!link && link.href.includes(window.location.hostname) && link.href.includes(':8099'),
|
|
link && link.href);
|
|
ok('Edit buttons disabled while a session runs',
|
|
[...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')].every(b => b.disabled));
|
|
ok('banner names the app being edited',
|
|
$('pixlet-editor-body').textContent.includes('test-editor-app'));
|
|
|
|
ok('no uncaught JS errors', errs.length === 0, errs.slice(0, 3));
|
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
|
process.exit(fail ? 1 : 0);
|
|
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 5).join('\n')); process.exit(1); });
|