diff --git a/scripts/install/install_mqtt_bridge.sh b/scripts/install/install_mqtt_bridge.sh old mode 100644 new mode 100755 diff --git a/scripts/utils/pixlet_config_editor.sh b/scripts/utils/pixlet_config_editor.sh old mode 100644 new mode 100755 index 8437623b..b0588e16 --- a/scripts/utils/pixlet_config_editor.sh +++ b/scripts/utils/pixlet_config_editor.sh @@ -18,20 +18,33 @@ # ./scripts/utils/pixlet_config_editor.sh # list installed apps # ./scripts/utils/pixlet_config_editor.sh # 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 +# PIXLET_EDITOR_TIMEOUT=600 ./scripts/utils/pixlet_config_editor.sh +# +# 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:-}" @@ -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" @@ -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" diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index f2557fd9..f66784e7 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -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", @@ -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", diff --git a/test/js/README.md b/test/js/README.md index dd91132c..023e0d9a 100644 --- a/test/js/README.md +++ b/test/js/README.md @@ -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. diff --git a/test/js/dom/test_tools_sections.js b/test/js/dom/test_tools_sections.js new file mode 100644 index 00000000..9325550c --- /dev/null +++ b/test/js/dom/test_tools_sections.js @@ -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