Files
LEDMatrix/test/js/dom/test_tools_sections.js
T
ChuckBuildsandClaude Opus 5 c7eb09db1d feat(tools): manage the MQTT bridge and Pixlet editor from the Tools tab
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
2026-09-09 11:20:18 -04:00

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