ci(smoke): sanitizer build + module/interaction/a11y/layout coverage

Turn the smoke test into a real runtime safety net. Waybar is now built with
AddressSanitizer and exercised end to end in a headless compositor:

- Tier 0: run everything under ASan; fail on ASan reports and Gtk/GLib
  criticals in the log (lib.sh assert_clean)
- Tier 1: per-module render matrix (modules.sh) — each headless-safe module
  rendered in isolation
- Tier 2: pointer interaction (interact.sh) — inject clicks via sway, assert
  on-click side effect and format-alt toggle; AT-SPI assertions (a11y.sh/.py)
  check module labels semantically instead of by pixels
- Tier 3: layout matrix (positions.sh) — top/bottom/left + HiDPI scale 2;
  second compositor run under labwc

Shared helpers extracted to lib.sh. Adds a workflow_dispatch `bless` input to
regenerate the golden reference in one click. Trigger push only on master to
avoid duplicate PR runs. AT-SPI and labwc steps are continue-on-error.
This commit is contained in:
Alex
2026-07-05 10:25:49 +02:00
parent 57c1ac789f
commit eb10a511f0
10 changed files with 524 additions and 135 deletions
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# Shared helpers for the headless Waybar smoke/render/interaction tests.
#
# A test sources this file, then typically:
# smoke::setup
# smoke::start_compositor
# smoke::launch_waybar <config> [style]
# smoke::assert_alive
# smoke::assert_clean # ASan/UBSan/Gtk-CRITICAL/segfault gate
# smoke::screenshot out.png
# smoke::stop
#
# Environment knobs (optional): WIDTH HEIGHT SCALE COMPOSITOR WAYBAR_BIN
set -uo pipefail
WIDTH="${WIDTH:-1920}"
HEIGHT="${HEIGHT:-1080}"
SCALE="${SCALE:-1}"
COMPOSITOR="${COMPOSITOR:-sway}"
WAYBAR_BIN="${WAYBAR_BIN:-waybar}"
SMOKE_LOG=""
COMP_PID=""
WAYBAR_PID=""
smoke::setup() {
export XDG_RUNTIME_DIR="$(mktemp -d)"
chmod 700 "$XDG_RUNTIME_DIR"
export WLR_BACKENDS=headless
export WLR_RENDERER=pixman
export WLR_LIBINPUT_NO_DEVICES=1
export LIBGL_ALWAYS_SOFTWARE=1
# Keep ASan/UBSan noise low but fatal on real errors (leaks in GTK/glib are
# too noisy to gate on, so only memory-corruption and UB abort the run).
export ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=1:detect_odr_violation=0"
export UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1"
}
smoke::start_compositor() {
case "$COMPOSITOR" in
sway)
local cfg; cfg="$(mktemp)"
cat > "$cfg" <<EOF
output HEADLESS-1 resolution ${WIDTH}x${HEIGHT} scale ${SCALE} background #000000 solid_color
EOF
sway -c "$cfg" >/tmp/compositor.log 2>&1 &
COMP_PID=$!
;;
labwc)
# labwc reads outputs from the wlroots headless backend directly.
labwc >/tmp/compositor.log 2>&1 &
COMP_PID=$!
;;
*)
echo "::error::unknown compositor '$COMPOSITOR'"; return 1 ;;
esac
local sock=""
for _ in $(seq 1 40); do
sock="$(find "$XDG_RUNTIME_DIR" -maxdepth 1 -name 'wayland-*' ! -name '*.lock' 2>/dev/null | head -1 || true)"
[ -n "$sock" ] && break
sleep 0.5
done
if [ -z "$sock" ]; then
echo "::error::$COMPOSITOR did not create a Wayland socket"; cat /tmp/compositor.log || true; return 1
fi
export WAYLAND_DISPLAY="$(basename "$sock")"
# sway IPC socket, so swaymsg (cursor injection, clean exit) can reach it.
if [ "$COMPOSITOR" = sway ]; then
for _ in $(seq 1 10); do
SWAYSOCK="$(find "$XDG_RUNTIME_DIR" -maxdepth 1 -name 'sway-ipc.*.sock' 2>/dev/null | head -1 || true)"
[ -n "$SWAYSOCK" ] && break
sleep 0.3
done
export SWAYSOCK
fi
echo "compositor '$COMPOSITOR' up on WAYLAND_DISPLAY=$WAYLAND_DISPLAY (${WIDTH}x${HEIGHT} scale ${SCALE})"
}
# smoke::launch_waybar <config> [style] [settle_seconds]
smoke::launch_waybar() {
local config="$1" style="${2:-}" settle="${3:-6}"
SMOKE_LOG="$(mktemp)"
local args=(-c "$config" -l debug)
[ -n "$style" ] && args+=(-s "$style")
"$WAYBAR_BIN" "${args[@]}" >"$SMOKE_LOG" 2>&1 &
WAYBAR_PID=$!
sleep "$settle"
}
smoke::log() { cat "$SMOKE_LOG" 2>/dev/null || true; }
smoke::assert_alive() {
if ! kill -0 "$WAYBAR_PID" 2>/dev/null; then
echo "::error::waybar exited early (crashed on startup)"
echo "::group::waybar log"; smoke::log; echo "::endgroup::"
return 1
fi
echo "✓ waybar is alive"
}
# Fail on sanitizer reports and GTK/GLib criticals in the log.
smoke::assert_clean() {
local bad
bad="$(grep -iE 'AddressSanitizer|runtime error:|LeakSanitizer|Gtk-CRITICAL|GLib-CRITICAL|GLib-GObject-CRITICAL|assertion .*failed|segfault|terminate called|SUMMARY: .*Sanitizer' "$SMOKE_LOG" || true)"
if [ -n "$bad" ]; then
echo "::error::waybar reported sanitizer/critical issues:"
echo "$bad"
echo "::group::full waybar log"; smoke::log; echo "::endgroup::"
return 1
fi
echo "✓ no sanitizer/critical issues in log"
}
smoke::screenshot() {
local out="$1"
grim "$out"
echo "✓ screenshot -> $out"
}
# Assert the top bar strip actually rendered something (not a uniform color).
smoke::assert_not_blank() {
local img="$1" h="${2:-30}"
local sd
sd="$(convert "$img" -crop "${WIDTH}x${h}+0+0" +repage -colorspace Gray -format '%[fx:standard_deviation]' info:)"
echo "bar strip stddev = $sd"
awk "BEGIN{ exit !($sd > 0.01) }" || { echo "::error::bar strip is blank in $img"; return 1; }
echo "✓ bar rendered content"
}
smoke::stop() {
kill "$WAYBAR_PID" 2>/dev/null || true
swaymsg -q exit 2>/dev/null || kill "$COMP_PID" 2>/dev/null || true
wait 2>/dev/null || true
}