Files
bfe-core1106-sdk/qemu/tests/ui-drive.sh
T
NoahandClaude Fable 5.1 8805b6106c qemu: review pass over the rig driver and boot script
Four review passes with fixes between them (flare-edge's flow-framework
review, 2026-09-09). qmp.py: drive() split out of a 330-line dispatcher,
every verb guarded so a raising verb records a fatal row instead of ending
the run, the shot path sanitised, the rs485 and wait verbs judged through
shared helpers; imgtools.py: a bench subcommand for phash/structural
timings and a colour probe that samples instead of scanning the frame;
ui-drive.sh: the boot poll no longer walks every pixel per tick and the
simulator's control socket path is passed as one word. Offline tests: 8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
2026-09-09 13:52:47 -06:00

319 lines
14 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 [--seed FILE] [--refs FILE] [--rs485-devices LIST] <zImage-virt> <script> [out-dir]
# <script> is a qmp.py `drive` script: see its docstring for the commands.
# --seed FILE a seed manifest (flat YAML key: value, see flare-edge
# tools/seed-fixtures.py) run through that tool and staged
# into userdata/warden BEFORE boot, via mkimage.sh's SEED_DIR
# hook -- so a screen's first read at startup already sees
# it, not a value written after the race is already lost.
# Needs FLARE_EDGE=<checkout> to find the tool (same
# convention as ota-apply.sh / portal-scenario.sh).
# --refs FILE passed straight through to qmp.py drive's --refs (the
# region/ocr reference JSON); see qmp.py's own docstring.
# --rs485-devices ADDR:SLUG[,ADDR:SLUG...]
# put simulated Modbus devices on the VM's RS485 bus: the
# UART is attached to a unix socket (run.sh --rs485), socat
# turns it into a pty, and flare-edge tools/modbus-sim/mbsim.py
# serves the named corpus profiles on it, so warden-modbus in
# the guest discovers and identifies devices the way it does
# on a panel with real controllers on the header. Needs
# FLARE_EDGE and socat.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
QDIR="$(cd "$HERE/.." && pwd)" # qemu/
SEED_FILE=""
REFS_FILE=""
RS485_DEVICES=""
ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--seed) SEED_FILE="${2:?--seed needs a manifest path}"; shift 2 ;;
--refs) REFS_FILE="${2:?--refs needs a path}"; shift 2 ;;
--rs485-devices) RS485_DEVICES="${2:?--rs485-devices needs ADDR:SLUG[,...]}"; shift 2 ;;
--) shift; ARGS+=("$@"); break ;;
-*) echo "FATAL: unknown option '$1' (usage: $0 [--seed FILE] [--refs FILE] [--rs485-devices LIST] <zImage> <script> [out-dir])" >&2; exit 1 ;;
*) ARGS+=("$1"); shift ;;
esac
done
ZIMAGE="${ARGS[0]:-}"
SCRIPT="${ARGS[1]:-}"
OUTDIR="${ARGS[2]:-$QDIR/out/ui-drive}"
if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then
echo "FATAL: usage: $0 [--seed FILE] [--refs FILE] [--rs485-devices LIST] <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
if [ -n "$SEED_FILE" ] && [ ! -f "$SEED_FILE" ]; then
echo "FATAL: no seed manifest at '$SEED_FILE'" >&2
exit 1
fi
if [ -n "$SEED_FILE" ] && { [ -z "${FLARE_EDGE:-}" ] || [ ! -f "$FLARE_EDGE/tools/seed-fixtures.py" ]; }; then
echo "FATAL: --seed needs FLARE_EDGE to point at a flare-edge checkout (seed-fixtures.py not found under '${FLARE_EDGE:-}')" >&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)"
# Every run gets its own image and initramfs: mkinitramfs.sh, mkimage.sh and
# run.sh all read $OUT, and with the shared default (qemu/out) two concurrent
# runs built and booted the SAME disk.img, so a seeded VM could read back
# another run's state (SDK #20). The image is sparse but is written by the
# guest, and /tmp is a tmpfs on the dev box, so this lives under $TMPDIR on
# real disk; the pinned busybox stays shared and read-only through BUSYBOX.
avail_kb="$(df --output=avail -k "${TMPDIR:-/tmp}" | tail -1 | tr -d ' ')"
if [ "${avail_kb:-0}" -lt 1048576 ]; then
echo "FATAL: ${TMPDIR:-/tmp} has under 1 GB free; point TMPDIR at real disk (see flows README)" >&2
exit 1
fi
mkdir -p "$QDIR/out"
RUN_OUT="$(mktemp -d "${TMPDIR:-/tmp}/wqd-out.XXXXXX")"
export OUT="$RUN_OUT"
export BUSYBOX="${BUSYBOX:-$QDIR/out/busybox-armv7l}"
QEMU_PID=""
SIM_PIDS=""
# SIGTERM first (lets qemu/socat/mbsim.py close their sockets and pty
# cleanly), then SIGKILL if that didn't take: a process wedged on a full pty
# buffer or an uninterruptible I/O wait under host load must not outlive this
# script, or a long batch (flow-run-all.sh --jobs N) slowly accumulates
# orphaned simulator/VM processes competing with the runs still in flight.
reap() {
local p="$1"
kill "$p" 2>/dev/null || return 0
for _i in $(seq 1 20); do
kill -0 "$p" 2>/dev/null || return 0
sleep 0.1
done
kill -KILL "$p" 2>/dev/null || true
}
cleanup() {
for p in $SIM_PIDS; do reap "$p"; done
if [ -n "$QEMU_PID" ]; then
python3 "$HERE/qmp.py" "$WORK/qmp.sock" quit 2>/dev/null || true
sleep 1
reap "$QEMU_PID"
fi
cp "$WORK/console.log" "$OUTDIR/console.log" 2>/dev/null || true
cp "$WORK/mbsim.log" "$OUTDIR/mbsim.log" 2>/dev/null || true
rm -rf "$WORK" "$RUN_OUT"
}
trap cleanup EXIT
bash "$QDIR/mkinitramfs.sh"
if [ -n "$SEED_FILE" ]; then
# Stage the settings files BEFORE mkimage.sh builds the disk: userdata is
# baked into the image up front (see qemu/README.md's boundary table --
# there is no mount-after-boot step this rig could inject files through),
# so anything a screen reads at startup has to be in place before -kernel
# even runs, not written into a running VM.
python3 "$FLARE_EDGE/tools/seed-fixtures.py" "$SEED_FILE" "$WORK/seed"
SEED_DIR="$WORK/seed" bash "$QDIR/mkimage.sh"
else
bash "$QDIR/mkimage.sh"
fi
# 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" ${RS485_DEVICES:+--rs485 "$WORK/rs.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
if [ -n "$RS485_DEVICES" ]; then
# qemu created rs.sock at launch (server=on,wait=off). socat gives the
# simulator the serial device it expects; mbsim then answers the guest's
# Modbus polls with the corpus profiles' own register maps.
command -v socat >/dev/null || { echo "FATAL: --rs485-devices needs socat" >&2; exit 1; }
[ -n "${FLARE_EDGE:-}" ] && [ -f "$FLARE_EDGE/tools/modbus-sim/mbsim.py" ] || {
echo "FATAL: --rs485-devices needs FLARE_EDGE to point at a flare-edge checkout (mbsim.py)" >&2; exit 1; }
for _i in $(seq 1 50); do [ -S "$WORK/rs.sock" ] && break; sleep 0.1; done
[ -S "$WORK/rs.sock" ] || {
echo "FATAL: rs485 bus socket never appeared at $WORK/rs.sock (qemu's --rs485 chardev never came up)" >&2
tail -25 "$WORK/console.log" >&2
exit 1
}
socat "UNIX-CONNECT:$WORK/rs.sock" "PTY,link=$WORK/rs.pty,raw,echo=0" > "$WORK/socat.log" 2>&1 &
socat_pid=$!
SIM_PIDS="$SIM_PIDS $socat_pid"
# Break out the moment socat dies rather than always spending the full
# poll budget: a socat that never links the pty is usually already gone
# (bad UNIX-CONNECT target, no pty node available), and kill -0 catches
# that in one tick instead of five seconds.
for _i in $(seq 1 50); do
[ -e "$WORK/rs.pty" ] && break
kill -0 "$socat_pid" 2>/dev/null || break
sleep 0.1
done
[ -e "$WORK/rs.pty" ] || {
echo "FATAL: rs485 socat never created rs.pty (see $WORK/socat.log)" >&2
cat "$WORK/socat.log" >&2
exit 1
}
dev_args=()
IFS=',' read -r -a _devs <<< "$RS485_DEVICES"
for d in "${_devs[@]}"; do dev_args+=(--device "$d"); done
# --control is the runtime lever: qmp.py's `rs485 silence|restore ADDR`
# verb talks to it, so a flow can take a device off the bus mid-run.
python3 "$FLARE_EDGE/tools/modbus-sim/mbsim.py" --port "$WORK/rs.pty" --control "$WORK/rs.ctl" "${dev_args[@]}" > "$WORK/mbsim.log" 2>&1 &
mbsim_pid=$!
SIM_PIDS="$SIM_PIDS $mbsim_pid"
for _i in $(seq 1 50); do
[ -S "$WORK/rs.ctl" ] && break
kill -0 "$mbsim_pid" 2>/dev/null || break
sleep 0.1
done
{ [ -S "$WORK/rs.ctl" ] && kill -0 "$mbsim_pid" 2>/dev/null; } || {
echo "FATAL: rs485 simulator (mbsim.py) never came up (see $WORK/mbsim.log)" >&2
cat "$WORK/mbsim.log" >&2
exit 1
}
echo "== rs485 simulator: $RS485_DEVICES on $WORK/rs.pty"
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.
#
# The count only has to clear a fixed threshold (32), not be exact, so this
# samples every 32nd pixel instead of all ~518K (720x720) of them, and bails
# the moment the threshold clears instead of always finishing the scan. A
# still-flat splash reads identically under any stride -- every sampled pixel
# is the same colour either way, so sampling can never manufacture a false
# "ready" -- and a rendered frame has enough variety that a sparse sample
# clears 32 distinct colours just as reliably. This loop runs on the same
# host CPU the comment above names as the bottleneck, on every poll tick of
# every flow's boot, so the per-tick cost matters; a plain byte loop here also
# avoids adding a Pillow dependency to a boot check that today has none (see
# qmp.py's own imgtools/Pillow deferral -- a plain nav/tap flow must keep
# booting on a host without Pillow installed).
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 "
d = open('$WORK/probe.ppm', 'rb').read()
colors = set()
for i in range(15, len(d), 32 * 3):
colors.add(d[i:i+3])
if len(colors) > 32:
break
print(len(colors))
" 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
DRIVE_ARGS=(--ctl "$WORK/ctl.sock" --console "$WORK/console.log")
# Only passed when given: qmp.py's own default (<outdir>/refs.json, see its
# docstring) is right for the common case of one refs file living next to a
# flow's other fixtures, and forcing a path here would just duplicate that
# default in two places.
[ -n "$REFS_FILE" ] && DRIVE_ARGS+=(--refs "$REFS_FILE")
python3 "$HERE/qmp.py" "$WORK/qmp.sock" drive "$SCRIPT" "$OUTDIR" \
${RS485_DEVICES:+--rs485-control "$WORK/rs.ctl"} \
"${DRIVE_ARGS[@]}" || 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)"