Files
bfe-core1106-sdk/qemu/tests/ui-drive.sh
T
NoahandClaude Fable 5.1 8a57057053 Bridge warden-ui's debug channel out of the qemu VM
The rig could drive the UI and detect one outcome: the process died. Nothing
could ask the UI what page it was on or what a tap would land on, because
that channel is a FIFO inside the guest and the initramfs is busybox-only
with no sshd. Scenarios therefore asserted nothing and screenshots went
unread.

run.sh --ctl exposes a second pci-serial port as a unix socket, the same
device the RS485 bridge already rides, listed first so it is always ttyS0.
It also puts warden.ctl on the kernel command line, and init bridges only
when that marker is present: a VM launched with --rs485 alone has a ttyS0
too, and that one is the Modbus wire. The bridge relays one command line in
and the FIFO's reply out, then a sentinel so the reader needs no timeout.

qmp.py gains the channel verbs (nav, page, stats, hit, assert_page,
assert_hit), records every step to results.jsonl as ok/fail/fatal, continues
past an assertion mismatch so one run reports every broken expectation, and
checks the console after EVERY step for the stage-2 init's EXITED line so a
crash is pinned to the step that caused it. assert_hit matches the widget's
bounding box: an icon has no usable caption and two list rows share a class,
but the geometry the UI itself resolved is exact.

The vocabulary is what tools/warden-ctl already speaks over SSH to a real
panel, so a script that runs here runs there. Verified end to end on the rig
(11/11 verbs round-tripped) and against the bench panel, where the same
commands returned byte-identical results.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013aHKWzT5EF86RFKRMtAv9n
2026-09-07 21:54:52 -06:00

162 lines
6.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# Drive the LVGL UI through a scripted interaction and collect screenshots.
#
# ui-shot.sh proves touch reaches the UI in one tap; this is the same rig for
# work that needs a SEQUENCE — swipe through the app rows, open a submenu, tap a
# tab, bring up the keyboard — with a screendump wherever the script asks for
# one. One boot serves the whole script, because booting per step (TCG, no KVM)
# costs about a minute and a real interaction is thirty steps.
#
# It also FAILS on a UI that died mid-script. The framebuffer keeps its last
# frame when warden-ui crashes, so screendumps carry on returning a plausible
# picture of a program that no longer exists; stage-2 init announces the exit on
# the console (see rootfs/sbin/init) and this greps for it after the run.
#
# FAILS CLOSED on missing prerequisites.
#
# Usage: ui-drive.sh <zImage-virt> <script> [out-dir]
# <script> is a qmp.py `drive` script: see its docstring for the commands.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
QDIR="$(cd "$HERE/.." && pwd)" # qemu/
ZIMAGE="${1:-}"
SCRIPT="${2:-}"
OUTDIR="${3:-$QDIR/out/ui-drive}"
if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then
echo "FATAL: usage: $0 <zImage> <script> [out-dir]: the virt.fragment kernel variant" >&2
exit 1
fi
if [ -z "$SCRIPT" ] || [ ! -f "$SCRIPT" ]; then
echo "FATAL: no drive script at '$SCRIPT'" >&2
exit 1
fi
[ -x "$QDIR/payload/warden-ui" ] || {
echo "FATAL: no qemu/payload/warden-ui: build it with flare-edge tools/build-ui-vm.sh" >&2
exit 1
}
command -v qemu-system-arm >/dev/null || {
echo "FATAL: qemu-system-arm not on PATH: see qemu/README.md" >&2
exit 1
}
mkdir -p "$OUTDIR"
# Short-named scratch: AF_UNIX socket paths are capped at ~108 chars.
WORK="$(mktemp -d /tmp/wqd.XXXXXX)"
QEMU_PID=""
cleanup() {
if [ -n "$QEMU_PID" ]; then
python3 "$HERE/qmp.py" "$WORK/qmp.sock" quit 2>/dev/null || true
sleep 1
kill "$QEMU_PID" 2>/dev/null || true
fi
cp "$WORK/console.log" "$OUTDIR/console.log" 2>/dev/null || true
rm -rf "$WORK"
}
trap cleanup EXIT
bash "$QDIR/mkinitramfs.sh"
bash "$QDIR/mkimage.sh"
# Random hostfwd ports can collide. Detect qemu's early bind failure and retry
# with a fresh base rather than failing spuriously.
for _attempt in 1 2 3; do
PORT=$((21000 + RANDOM % 20000))
: > "$WORK/console.log"
bash "$QDIR/run.sh" --kernel "$ZIMAGE" --display headless --qmp "$WORK/qmp.sock" \
--ctl "$WORK/ctl.sock" \
--ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \
> "$WORK/console.log" 2>&1 &
QEMU_PID=$!
sleep 3
kill -0 "$QEMU_PID" 2>/dev/null && break
if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then
echo "== hostfwd port collision on base $PORT, retrying"
QEMU_PID=""
continue
fi
echo "FATAL: VM died at launch:" >&2
tail -20 "$WORK/console.log" >&2
exit 1
done
if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then
echo "FATAL: could not launch the VM after 3 port attempts" >&2
exit 1
fi
echo "== waiting for warden-ui"
deadline=$((SECONDS + 180))
while [ $SECONDS -lt $deadline ]; do
grep -aq 'init: starting warden-ui' "$WORK/console.log" && break
kill -0 "$QEMU_PID" 2>/dev/null || { echo "FATAL: VM exited early" >&2; tail -25 "$WORK/console.log" >&2; exit 1; }
sleep 2
done
grep -aq 'init: starting warden-ui' "$WORK/console.log" || {
echo "FATAL: warden-ui never started (no fb0? wrong kernel?)" >&2
tail -25 "$WORK/console.log" >&2
exit 1
}
# A started process is not a rendered frame. Poll screendumps until the panel
# stops being a single flat colour, on a bounded deadline: TCG renders CPU-bound
# and a loaded host can be arbitrarily slow, so this is never a fixed sleep.
echo "== waiting for the first real frame"
deadline=$((SECONDS + 180))
ready=0
while [ $SECONDS -lt $deadline ]; do
python3 "$HERE/qmp.py" "$WORK/qmp.sock" screendump "$WORK/probe.ppm" 2>/dev/null || { sleep 2; continue; }
colors="$(python3 -c "
import sys
d=open('$WORK/probe.ppm','rb').read()
print(len(set(d[i:i+3] for i in range(15, len(d), 3))))
" 2>/dev/null || echo 0)"
[ "${colors:-0}" -gt 32 ] && { ready=1; break; }
sleep 3
done
[ "$ready" = 1 ] || {
echo "FATAL: the UI never rendered a real frame" >&2
tail -25 "$WORK/console.log" >&2
exit 1
}
# The control bridge (init -> warden-ui's debug FIFO, see run.sh --ctl) comes
# up with the UI; a script's first `page`/`hit` must not race it. Bounded, and
# fail-closed: a scenario that asserts on UI state needs the channel, and a
# silently absent one would turn every assertion into an infrastructure error
# dressed as a test result.
echo "== waiting for the control bridge"
deadline=$((SECONDS + 60))
until grep -aq 'init: control bridge on' "$WORK/console.log"; do
[ $SECONDS -lt $deadline ] || {
echo "FATAL: the control bridge never announced itself (run.sh --ctl / init marker)" >&2
tail -25 "$WORK/console.log" >&2
exit 1
}
sleep 1
done
echo "== driving $SCRIPT"
# Every step lands in results.jsonl (ok / fail / fatal); an assertion mismatch
# is a `fail` and the run continues, so one run reports every broken
# expectation. The driver's exit status is the verdict; capture it rather than
# let `set -e` skip the backstop and the summary below.
drive_rc=0
python3 "$HERE/qmp.py" "$WORK/qmp.sock" drive "$SCRIPT" "$OUTDIR" \
--ctl "$WORK/ctl.sock" --console "$WORK/console.log" || drive_rc=$?
# The UI must still be alive: see the header. The driver checks this after
# every step and pins a crash to the step that caused it; this is the
# backstop for a death after the last step, or a driver that itself fell over.
if grep -aq 'warden-ui EXITED' "$WORK/console.log"; then
echo "FATAL: warden-ui DIED during the run:" >&2
grep -a -A22 'warden-ui EXITED' "$WORK/console.log" >&2
exit 1
fi
if [ "$drive_rc" -ne 0 ]; then
echo "UI-DRIVE-FAIL: see $OUTDIR/results.jsonl" >&2
grep -E '"status": "(fail|fatal)"' "$OUTDIR/results.jsonl" >&2 || true
exit 1
fi
echo "UI-DRIVE-PASS (results in $OUTDIR/results.jsonl, screenshots in $OUTDIR)"