qemu: two tests the review pass left on disk

tests/scripts/fullscreen-toggle-tracks-real-state.txt pins the debug
channel's `fullscreen toggle` deciding from the dashboard's real state
rather than a private flag that `home` and a real tap left stale (rig:
14 ok). tests/test-ui-drive-rs485.sh runs offline with a fake run.sh and
socat and proves ui-drive.sh --rs485-devices fails closed when the
socket, pty or control socket never appears, dumps mbsim.log on failure,
and kills a simulator that ignores SIGTERM. README lists both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
This commit is contained in:
Noah
2026-09-09 13:54:35 -06:00
co-authored by Claude Fable 5.1
parent 8805b6106c
commit bda6c6c633
3 changed files with 233 additions and 1 deletions
@@ -0,0 +1,39 @@
# `fullscreen toggle` must read the dashboard's REAL state, not a belief the
# FIFO's own `fullscreen` verb tracked on the side.
#
# warden_debug.c's `fullscreen` command used to keep a private `static bool
# on` as its only record of state, and nothing kept it in step with the real
# dashboard: `home` clears fullscreen by calling
# warden_screen_overview_set_fullscreen(false) directly (every hardware run's
# preamble, since a live panel is wherever the last flow or an operator left
# it), and a real tap on the dashboard toggles it through the UI's own
# tap-catcher. Either way the FIFO's belief was left stale. A `fullscreen
# toggle` right after either one then decided its next state from that stale
# belief: if `on` was still true from an earlier `fullscreen on`, toggling
# set it to false -- which coincidentally MATCHED the already-off real state,
# so the dashboard never actually turned on, yet the reply still said
# "fullscreen off: ok". See test_qmp_drive.py's FullscreenToggleTracksRealState
# for the same scenario driven offline against a faked control channel (both
# the pre-fix and fixed shapes).
#
# `wake` first, as every compiled flow does: by the time the control bridge
# is up the rig may already sit dimmed, and sleep.c swallows the touch that
# wakes a dimmed panel (flare-edge #148).
wake
sleep 1
assert_page Dashboard/Dashboard
assert_hit 47 676 obj box=12,640,72x72
ctl fullscreen on
sleep 1
assert_hit 47 676 widget box=0,0,720x720
home
sleep 1
assert_page Dashboard/Dashboard
# home must have cleared the REAL state -- this is home-leaves-fullscreen.txt's
# own check, repeated here so a failure below is unambiguously the toggle's
# fault and not a home regression.
assert_hit 47 676 obj box=12,640,72x72
# The dashboard is really off now. A correct toggle from here can only go on.
ctl fullscreen toggle
sleep 1
assert_hit 47 676 widget box=0,0,720x720
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
# Offline regression test for ui-drive.sh's rs485 simulator startup and its
# process cleanup: no real QEMU or kernel image. A fake qemu/run.sh stands in
# for the VM (it optionally binds the rs.sock qemu's --rs485 chardev would
# create) and, for one case, a fake socat stands in ahead of it on PATH. This
# is the same "copy the real script beside stand-in siblings" trick
# tests/flows/run-flow-run-all-tests.sh uses for flow-run-rig.sh, one layer
# down at ui-drive.sh's own dependencies.
#
# What is worth pinning:
# - the rs.sock/rs.pty/rs.ctl waits fail closed instead of printing
# "== rs485 simulator: ..." over a bus nothing is actually serving --
# before this fix, a socat or mbsim.py that never came up looked, from
# ui-drive.sh's own output, exactly like a live one, and every later
# rs485 assertion failed for a reason buried in a log nobody was pointed
# at.
# - cleanup() escalates to SIGKILL for a sim process that ignores SIGTERM,
# instead of leaving it to outlive the script (an accumulation of
# orphaned simulator processes on a loaded CI runner).
#
# bash test-ui-drive-rs485.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRATCH="$(mktemp -d /tmp/uidrs485.XXXXXX)"
trap 'rm -rf "$SCRATCH"' EXIT
FAIL=0
pass() { printf '[PASS] %s\n' "$1"; }
fail() { printf '[FAIL] %s\n' "$1"; FAIL=1; }
ZIMAGE="$SCRATCH/fake-zImage"
SCRIPT="$SCRATCH/fake.txt"
: > "$ZIMAGE"
printf 'wake\n' > "$SCRIPT"
# build_rig WORKDIR: lay out WORKDIR/qemu with a copy of the real (fixed)
# ui-drive.sh and qmp.py next to stand-in mkinitramfs.sh/mkimage.sh/run.sh/
# payload, so ui-drive.sh's own HERE/QDIR resolve inside WORKDIR and nothing
# under the real qemu/out/ is ever touched.
build_rig() {
local work="$1"
mkdir -p "$work/qemu/tests" "$work/qemu/payload"
cp "$HERE/ui-drive.sh" "$work/qemu/tests/ui-drive.sh"
cp "$HERE/qmp.py" "$work/qemu/tests/qmp.py"
chmod +x "$work/qemu/tests/ui-drive.sh"
printf '#!/usr/bin/env bash\nexit 0\n' > "$work/qemu/mkinitramfs.sh"
printf '#!/usr/bin/env bash\nexit 0\n' > "$work/qemu/mkimage.sh"
cat > "$work/qemu/run.sh" <<'EOS'
#!/usr/bin/env bash
# Stand-in for qemu/run.sh: no real VM, just enough to drive ui-drive.sh's
# rs485 startup logic offline. TEST_MAKE_RS_SOCK=1 binds a listening AF_UNIX
# socket at --rs485's path (as qemu's server=on,wait=off chardev would);
# unset/0 leaves it absent, simulating qemu never wiring up the bus. Stays
# up 8s -- past ui-drive.sh's 3s post-launch liveness check and the rs485
# waits' poll budgets -- then exits, so a run that gets past the rs485 block
# still fails fast (VM exited early) instead of idling out the 180s deadline.
rs485_sock=""
while [ $# -gt 0 ]; do
case "$1" in
--rs485) rs485_sock="$2"; shift 2 ;;
*) shift ;;
esac
done
exec python3 -c '
import os, socket, sys, time
sock_path = sys.argv[1] if len(sys.argv) > 1 else ""
if sock_path and os.environ.get("TEST_MAKE_RS_SOCK") == "1":
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(sock_path)
s.listen(1)
time.sleep(8)
' "$rs485_sock"
EOS
chmod +x "$work/qemu/mkinitramfs.sh" "$work/qemu/mkimage.sh" "$work/qemu/run.sh"
: > "$work/qemu/payload/warden-ui"
chmod +x "$work/qemu/payload/warden-ui"
}
# run_rig WORKDIR: invoke the copied ui-drive.sh with a bounded outer timeout
# (the test's own safety net, matching run-flow-run-all-tests.sh) and stash
# combined output + exit code in the globals below.
RIG_OUT=""
RIG_RC=0
run_rig() {
local work="$1"
RIG_OUT="$(cd "$work" && timeout -k 5 40 bash "$work/qemu/tests/ui-drive.sh" \
--rs485-devices "5:bare" "$ZIMAGE" "$SCRIPT" "$work/out" 2>&1)"
RIG_RC=$?
}
# --- case A: qemu's rs.sock never appears -----------------------------------
work="$SCRATCH/a"; build_rig "$work"
FLARE_EDGE="$SCRATCH/flare-edge-a"
mkdir -p "$FLARE_EDGE/tools/modbus-sim"
printf '#!/usr/bin/env python3\nimport sys; sys.exit(1)\n' > "$FLARE_EDGE/tools/modbus-sim/mbsim.py"
RIG_OUT="$(unset TEST_MAKE_RS_SOCK; export FLARE_EDGE; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
[ "$RIG_RC" -ne 0 ] \
&& pass "case A: never-appeared rs.sock fails the run (rc=$RIG_RC)" \
|| fail "case A: never-appeared rs.sock fails the run: rc=$RIG_RC, out: $RIG_OUT"
printf '%s\n' "$RIG_OUT" | grep -qF 'FATAL: rs485 bus socket never appeared' \
&& pass "case A: FATAL names the missing rs.sock" \
|| fail "case A: FATAL names the missing rs.sock: $RIG_OUT"
printf '%s\n' "$RIG_OUT" | grep -qF '== rs485 simulator:' \
&& fail "case A: printed the rs485-live success line over a bus that was never up: $RIG_OUT" \
|| pass "case A: never claims the bus is live"
# --- case B: rs.sock is up but socat never creates rs.pty -------------------
work="$SCRATCH/b"; build_rig "$work"
FLARE_EDGE="$SCRATCH/flare-edge-b"
mkdir -p "$FLARE_EDGE/tools/modbus-sim"
printf '#!/usr/bin/env python3\nimport sys; sys.exit(1)\n' > "$FLARE_EDGE/tools/modbus-sim/mbsim.py"
FAKEBIN="$SCRATCH/fakebin-b"; mkdir -p "$FAKEBIN"
printf '#!/usr/bin/env bash\necho "FAKE SOCAT: simulated failure" >&2\nexit 1\n' > "$FAKEBIN/socat"
chmod +x "$FAKEBIN/socat"
RIG_OUT="$(export TEST_MAKE_RS_SOCK=1 FLARE_EDGE; export PATH="$FAKEBIN:$PATH"; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
[ "$RIG_RC" -ne 0 ] \
&& pass "case B: socat never linking rs.pty fails the run (rc=$RIG_RC)" \
|| fail "case B: socat never linking rs.pty fails the run: rc=$RIG_RC, out: $RIG_OUT"
printf '%s\n' "$RIG_OUT" | grep -qF 'FATAL: rs485 socat never created rs.pty' \
&& pass "case B: FATAL names the missing rs.pty" \
|| fail "case B: FATAL names the missing rs.pty: $RIG_OUT"
# --- case C: rs.sock+rs.pty are up but mbsim.py crashes before rs.ctl -------
work="$SCRATCH/c"; build_rig "$work"
FLARE_EDGE="$SCRATCH/flare-edge-c"
mkdir -p "$FLARE_EDGE/tools/modbus-sim"
cat > "$FLARE_EDGE/tools/modbus-sim/mbsim.py" <<'EOS'
#!/usr/bin/env python3
import sys
sys.stderr.write("FAKE MBSIM: simulated crash before opening the control socket\n")
sys.exit(1)
EOS
RIG_OUT="$(export TEST_MAKE_RS_SOCK=1 FLARE_EDGE; unset TEST_MBSIM_PID_FILE; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
[ "$RIG_RC" -ne 0 ] \
&& pass "case C: mbsim.py crashing before rs.ctl fails the run (rc=$RIG_RC)" \
|| fail "case C: mbsim.py crashing before rs.ctl fails the run: rc=$RIG_RC, out: $RIG_OUT"
printf '%s\n' "$RIG_OUT" | grep -qF 'FATAL: rs485 simulator (mbsim.py) never came up' \
&& pass "case C: FATAL names the dead simulator" \
|| fail "case C: FATAL names the dead simulator: $RIG_OUT"
printf '%s\n' "$RIG_OUT" | grep -qF 'FAKE MBSIM: simulated crash' \
&& pass "case C: mbsim.log is dumped on failure" \
|| fail "case C: mbsim.log is dumped on failure: $RIG_OUT"
# --- case D: a sim process that ignores SIGTERM is still reaped ------------
work="$SCRATCH/d"; build_rig "$work"
FLARE_EDGE="$SCRATCH/flare-edge-d"
mkdir -p "$FLARE_EDGE/tools/modbus-sim"
cat > "$FLARE_EDGE/tools/modbus-sim/mbsim.py" <<'EOS'
#!/usr/bin/env python3
# Comes up cleanly (binds --control so ui-drive.sh's rs.ctl wait passes),
# then ignores SIGTERM -- the process cleanup() must still SIGKILL.
import argparse, os, signal, socket, sys, time
ap = argparse.ArgumentParser()
ap.add_argument('--port')
ap.add_argument('--control')
ap.add_argument('--device', action='append', default=[])
args = ap.parse_args()
signal.signal(signal.SIGTERM, signal.SIG_IGN)
if args.control:
try:
os.unlink(args.control)
except FileNotFoundError:
pass
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(args.control)
s.listen(1)
pidfile = os.environ.get('TEST_MBSIM_PID_FILE')
if pidfile:
with open(pidfile, 'w') as f:
f.write(str(os.getpid()))
while True:
time.sleep(1)
EOS
PIDFILE="$SCRATCH/mbsim-d.pid"
RIG_OUT="$(export TEST_MAKE_RS_SOCK=1 FLARE_EDGE TEST_MBSIM_PID_FILE="$PIDFILE"; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
if [ -s "$PIDFILE" ]; then
mbsim_pid="$(cat "$PIDFILE")"
if ! kill -0 "$mbsim_pid" 2>/dev/null; then
pass "case D: a SIGTERM-ignoring sim process is dead once ui-drive.sh returns (SIGKILL fallback)"
else
fail "case D: pid $mbsim_pid is still alive after ui-drive.sh returned -- SIGKILL fallback did not fire"
kill -KILL "$mbsim_pid" 2>/dev/null || true # don't leak it out of the test either
fi
else
fail "case D: the fake mbsim.py never wrote its pid file, out: $RIG_OUT"
fi
[ "$FAIL" -eq 0 ] && echo "ALL UI-DRIVE RS485 TESTS PASSED" || echo "UI-DRIVE RS485 TESTS FAILED"
[ "$FAIL" -eq 0 ]