Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified scripts/install/install_mqtt_bridge.sh
100644 → 100755
Empty file.
78 changes: 66 additions & 12 deletions scripts/utils/pixlet_config_editor.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,33 @@
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
#
# Binds loopback only, and there is deliberately no flag to change that:
# `pixlet serve` has no authentication, and anything that can reach it can
# rewrite the app's config. To edit from another machine, forward the port --
# which authenticates as SSH and leaves nothing listening on the LAN:
# Binds the LAN by default, matching the web interface, which already serves
# 0.0.0.0:5000 with no authentication -- anything that can reach this can
# already reconfigure the display there. `pixlet serve` has no authentication
# either, so treat both the same way: fine on a home network, not on an open
# one. Override the bind and the session length with:
#
# PIXLET_EDITOR_HOST=127.0.0.1 ./scripts/utils/pixlet_config_editor.sh <app>
# PIXLET_EDITOR_TIMEOUT=600 ./scripts/utils/pixlet_config_editor.sh <app>
#
# For loopback-only editing from another machine, forward the port instead:
#
# ssh -L 8080:localhost:8080 pi@ledpi.local
#
# The session always ends by itself after PIXLET_EDITOR_TIMEOUT seconds
# (default 30 minutes). The display is stopped while editing, so a session
# left open would otherwise leave the panel dark indefinitely -- the timeout
# is what makes it safe to start one from the web interface.

set -eu

PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
PORT="${PIXLET_EDITOR_PORT:-8080}"
# Loopback only. See the header: pixlet serve is unauthenticated.
BIND_HOST="127.0.0.1"
# LAN by default; see the header for why, and how to force loopback.
BIND_HOST="${PIXLET_EDITOR_HOST:-0.0.0.0}"
# Hard stop, so the display cannot be left off by a forgotten session.
EDITOR_TIMEOUT="${PIXLET_EDITOR_TIMEOUT:-1800}"

APP_ID="${1:-}"

Expand Down Expand Up @@ -109,6 +122,19 @@ fi
# failure worth guarding against.
cleanup() {
echo ""
# Kill the serve child explicitly. `timeout` is started with --foreground so
# it shares this script's process group (without that it makes its own, and
# a group signal aimed at this script would orphan pixlet with the port
# still bound). Belt and braces: signal the recorded pid too, because a
# group signal only reaches it while the group is shared.
if [ -n "${SERVE_PID:-}" ] && kill -0 "$SERVE_PID" 2>/dev/null; then
kill -TERM "$SERVE_PID" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$SERVE_PID" 2>/dev/null || break
sleep 0.3
done
kill -KILL "$SERVE_PID" 2>/dev/null || true
fi
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
echo "Restarting the display service..."
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
Expand All @@ -122,22 +148,50 @@ if [ "$DISPLAY_WAS_RUNNING" = true ]; then
sudo systemctl stop ledmatrix
fi

if [ "$BIND_HOST" = "0.0.0.0" ]; then
REACH_HOST="$(hostname).local"
else
REACH_HOST="localhost"
fi

echo ""
echo "Editing: $APP_ID"
echo "App file: $STAR_FILE"
echo "URL: http://localhost:$PORT/"
echo "URL: http://$REACH_HOST:$PORT/"
echo ""
echo "Listening on localhost only -- pixlet serve has no authentication."
echo "From another machine, forward the port:"
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
if [ "$BIND_HOST" = "0.0.0.0" ]; then
echo "Reachable on the LAN, and pixlet serve has no authentication -- the"
echo "same footing as the web interface on port 5000. Set"
echo "PIXLET_EDITOR_HOST=127.0.0.1 to keep it to this machine."
else
echo "Listening on $BIND_HOST only. From another machine, forward the port:"
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
fi
echo ""
echo "Changes save straight to the real config as you make them."
echo "Press Ctrl+C when finished - the display restarts automatically."
echo "This session stops on its own after ${EDITOR_TIMEOUT}s regardless."
echo ""

cd "$APP_DIR"
"$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
# `timeout` owns the hard stop rather than the caller: the trap above restarts
# the display however this exits, so a session that outlives the person who
# started it still gives the panel back. Exit 124 is timeout's own code for
# "expired", which is a normal end here, not a failure.
# --foreground: stay in this script's process group so one signal reaches the
# whole session. Backgrounded + `wait` so the EXIT trap can run while the child
# is still alive; a foreground child would leave bash waiting on it instead.
timeout --foreground "$EDITOR_TIMEOUT" "$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
--host "$BIND_HOST" \
--port "$PORT" \
--no-browser \
--saveconfig "$CONFIG_FILE"
--saveconfig "$CONFIG_FILE" &
SERVE_PID=$!

status=0
wait "$SERVE_PID" || status=$?
if [ "$status" -eq 124 ]; then
echo "Session reached its ${EDITOR_TIMEOUT}s limit."
status=0
fi
exit "$status"
51 changes: 51 additions & 0 deletions test/fixtures/api_v3_url_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,23 @@
"OPTIONS"
]
],
[
"/api/v3/integrations/mqtt-bridge",
"api_v3.get_mqtt_bridge",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/integrations/mqtt-bridge/config",
"api_v3.update_mqtt_bridge_config",
[
"OPTIONS",
"PUT"
]
],
[
"/api/v3/logs",
"api_v3.get_logs",
Expand Down Expand Up @@ -747,6 +764,40 @@
"POST"
]
],
[
"/api/v3/starlark/editor/apps",
"api_v3.list_pixlet_editor_apps",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/editor/start",
"api_v3.start_pixlet_editor",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/editor/status",
"api_v3.get_pixlet_editor_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/editor/stop",
"api_v3.stop_pixlet_editor",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/install-pixlet",
"api_v3.install_pixlet",
Expand Down
1 change: 1 addition & 0 deletions test/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ nothing is listening, so it stays useful in a bare checkout.
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |

Point the DOM suites at a rig with a full plugin set when it matters — a dev box
with two plugins installed will pass while exercising very little.
Expand Down
116 changes: 116 additions & 0 deletions test/js/dom/test_tools_sections.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// 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); });
3 changes: 2 additions & 1 deletion test/js/run_all.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ 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'];
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 => {
Expand Down
Loading
Loading