Files
LEDMatrix/test/js/run_all.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

59 lines
2.3 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Runs the web-interface JS suites.
//
// node run_all.js unit suites, plus DOM suites if a
// web interface is reachable
// BASE=http://10.0.10.169:5000 node run_all.js point the DOM suites at a rig
//
// Unit suites need nothing but node. The DOM suites need `npm install` (jsdom)
// and a running web interface, because they deliberately test against the real
// server-rendered HTML and the real API rather than fixtures.
const { spawnSync } = require('child_process');
const http = require('http');
const path = require('path');
const fs = require('fs');
const BASE = process.env.BASE || 'http://localhost:5000';
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
function reachable(url) {
return new Promise(res => {
const req = http.get(url, r => { r.resume(); res(r.statusCode < 500); });
req.on('error', () => res(false));
req.setTimeout(4000, () => { req.destroy(); res(false); });
});
}
function run(file) {
const r = spawnSync(process.execPath, [path.join(__dirname, file)],
{ stdio: 'inherit', cwd: __dirname, env: process.env });
return r.status === 0;
}
(async () => {
const results = [];
for (const f of UNIT) results.push([f, run(f)]);
const haveJsdom = fs.existsSync(path.join(__dirname, 'node_modules', 'jsdom'));
const up = await reachable(BASE + '/');
if (!haveJsdom) {
console.log(`\nSKIPPING DOM suites: jsdom not installed (run: npm install)\n`);
} else if (!up) {
console.log(`\nSKIPPING DOM suites: no web interface reachable at ${BASE}`);
console.log(` start one with: EMULATOR=true python3 web_interface/app.py`);
console.log(` or point at a rig: BASE=http://<host>:5000 node run_all.js\n`);
} else {
console.log(`\nDOM suites against ${BASE}\n`);
for (const f of DOM) results.push([f, run(f)]);
}
const failed = results.filter(([, ok]) => !ok);
console.log('\n─── summary ───');
results.forEach(([f, ok]) => console.log(` ${ok ? 'PASS' : 'FAIL'} ${f}`));
if (failed.length) { console.log(`\n${failed.length} suite(s) failed\n`); process.exit(1); }
console.log('\nall suites passed\n');
})();