From 02fe15937e5f3d868ed7cf7873981dbb0746e3d4 Mon Sep 17 00:00:00 2001 From: "Brandon W. King" <70168+kingb@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:58:16 -0700 Subject: [PATCH] ci: add a real-window smoke gate (Xvfb + software Vulkan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo test never creates a window or GPU surface, so a winit/wgpu/X11 regression or a missing runtime dep (libxcursor1, libxkbcommon-x11-0, a monospace font) sails through CI. Add a smoke job that launches the real binary under Xvfb with mesa's lavapipe and drives it through the control socket: launch, ctl state, a typed shell round-trip, tab drag/reorder/cancel, and a stability soak — failing the build on any windowing regression, and uploading the rendered frame as an artifact. Assertions are extracted into scripts/smoke/window-smoke.sh (strict: every failure exits non-zero) and shared with the standalone container harness, refactored to delegate, so the two can't drift. Validated in an ubuntu:24.04 container against the checked-out tree. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 45 ++++++++++++++ scripts/smoke/window-smoke.sh | 83 ++++++++++++++++++++++++++ scripts/smoke/x11-container-smoke.sh | 89 ++++++++-------------------- 3 files changed, 154 insertions(+), 63 deletions(-) create mode 100755 scripts/smoke/window-smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4063652..40c77cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,51 @@ jobs: - name: Bench (compile gate) run: cargo bench --all --no-run + # Real-window smoke: the `check` job runs `cargo test`, which NEVER creates a + # window or GPU surface, so a winit/wgpu/X11 regression (or a missing runtime + # dep) sails through it. This job launches the actual binary under Xvfb with + # software Vulkan (lavapipe) and drives it via the control socket — the same + # path release bottles are smoke-tested on. Assertions live in + # scripts/smoke/window-smoke.sh (shared with the standalone container harness). + smoke: + name: real-window smoke (X11) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + # Build headers PLUS the RUNTIME libs a real window needs but `check` + # never installs — libxcursor1 + libxkbcommon-x11-0 (winit dlopens them; + # startup panics without) and a system monospace font — plus Xvfb and + # mesa's software Vulkan ICD. + - name: Install deps (build + runtime + Xvfb + software Vulkan) + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwayland-dev libxkbcommon-dev libx11-dev libxcursor-dev libxi-dev \ + libxrandr-dev libxcb1-dev libvulkan-dev mesa-vulkan-drivers \ + libfontconfig1-dev fonts-dejavu-core \ + xvfb x11-apps imagemagick libxi6 libxcursor1 libxkbcommon-x11-0 + - name: Build ember-term (release) + run: cargo build --release -p ember-app --bin ember-term + - name: Smoke under Xvfb + lavapipe + run: | + Xvfb :99 -screen 0 1280x800x24 >/dev/null 2>&1 & + sleep 2 + export DISPLAY=:99 + export VK_ICD_FILENAMES="$(ls /usr/share/vulkan/icd.d/lvp_icd*.json | head -1)" + export WGPU_BACKEND=vulkan WINIT_UNIX_BACKEND=x11 + export EMBER_SMOKE_BIN="$PWD/target/release/ember-term" + export EMBER_SMOKE_OUT="$PWD/smoke-artifacts" + bash scripts/smoke/window-smoke.sh + - name: Upload smoke screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: window-smoke-x11 + path: smoke-artifacts/*.png + if-no-files-found: ignore + licenses: name: license gate runs-on: ubuntu-latest diff --git a/scripts/smoke/window-smoke.sh b/scripts/smoke/window-smoke.sh new file mode 100755 index 0000000..c68a049 --- /dev/null +++ b/scripts/smoke/window-smoke.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Real-window smoke assertions for a PREBUILT ember-term binary against an +# ALREADY-RUNNING display + software Vulkan. The caller sets up the environment; +# this file is only the assertions, so both the standalone container harness +# (x11-container-smoke.sh) and the CI gate (.github/workflows/ci.yml) share one +# source of truth. +# +# EMBER_SMOKE_BIN=/path/to/ember-term \ +# DISPLAY=:99 VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.*.json \ +# WGPU_BACKEND=vulkan WINIT_UNIX_BACKEND=x11 \ +# scripts/smoke/window-smoke.sh [out-dir] +# +# Unlike `cargo test`, this exercises the real winit+wgpu window/surface path +# (renderer/paint/headless), so a windowing regression fails the build instead +# of shipping. Every check that matters exits non-zero on failure — this is a +# gate, not a report. +set -uo pipefail + +BIN="${EMBER_SMOKE_BIN:-${1:-}}" +OUT="${EMBER_SMOKE_OUT:-${2:-/tmp}}" +[ -n "$BIN" ] && [ -x "$BIN" ] || { echo "window-smoke: no executable binary (EMBER_SMOKE_BIN=$BIN)"; exit 2; } +mkdir -p "$OUT" +: "${DISPLAY:?window-smoke: DISPLAY must be set (start Xvfb first)}" + +fail() { echo " FAIL: $*"; [ -f /tmp/ember-smoke.log ] && tail -20 /tmp/ember-smoke.log | sed 's/^/ /'; exit 1; } +st() { "$BIN" ctl state 2>/dev/null; } +jq_py() { python3 -c "$1"; } + +echo "=== SMOKE 1: windowed app launches on the real display ===" +EMBER_CONTROL=1 "$BIN" >/tmp/ember-smoke.log 2>&1 & +APP=$! +sleep 8 +kill -0 "$APP" 2>/dev/null && echo " PASS: app alive after 8s (pid $APP)" || fail "app died on launch" + +echo "=== SMOKE 2: ctl reaches it + grid state parses ===" +st > /tmp/state1.json 2>&1 || fail "ctl state failed" +jq_py "import json,sys; s=json.load(open('/tmp/state1.json'))['state']; assert s['surface'][0]>0 and s['surface'][1]>0, 'bad surface'; print(' PASS: ctl state ok, surface', s['surface'])" \ + || fail "ctl state JSON unparseable / degenerate surface" + +echo "=== SMOKE 3: typed input round-trips through the shell ===" +"$BIN" ctl type 'echo win-smoke-$((6*7))' >/dev/null 2>&1 || fail "ctl type failed" +"$BIN" ctl key Enter >/dev/null 2>&1 || fail "ctl key failed" +sleep 2 +st | grep -q "win-smoke-42" && echo " PASS: shell echoed win-smoke-42" || fail "typed command did not round-trip" + +echo "=== SMOKE 4: visual capture (real window renders) ===" +if command -v xwd >/dev/null && command -v convert >/dev/null; then + xwd -root -silent | convert xwd:- "$OUT/window-smoke.png" 2>/dev/null \ + && echo " captured $OUT/window-smoke.png" || echo " WARN: capture failed (non-fatal)" +fi + +echo "=== SMOKE 5: drag/carry on the real window (reorder, cancel, content=selection) ===" +"$BIN" ctl chord cmd+t >/dev/null 2>&1 || fail "ctl chord (new tab) failed" +sleep 1 +TABS=$(st | jq_py "import json,sys; print(len(json.load(sys.stdin)['state']['tabs']))") || fail "tab count unreadable" +[ "$TABS" = "2" ] && echo " PASS: second tab opened (tabs=$TABS)" || fail "expected 2 tabs, got $TABS" +read -r W H <<<"$(st | jq_py "import json,sys; s=json.load(sys.stdin)['state']['surface']; print(s[0], s[1])")" +PANES_BEFORE=$(st | jq_py "import json,sys; print(len(json.load(sys.stdin)['state']['panes']))") +# Tab reorder: drag tab-1 chip toward tab-2 slot; just assert the drag resolves. +R1=$("$BIN" ctl drag 40 12 160 12 --steps 12 --paced 16 2>&1) +echo "$R1" | jq_py "import json,sys; d=json.load(sys.stdin); assert d.get('drag_ended'); print(' reorder drag ->', d['drag_ended'])" \ + || fail "reorder drag did not resolve: $(echo "$R1" | head -c 160)" +# Cancelled drag: must end 'cancel' and change nothing. +R2=$("$BIN" ctl drag $((W/2)) $((H/2)) $((W-30)) $((H/2)) --steps 10 --cancel 2>&1) +echo "$R2" | jq_py "import json,sys; d=json.load(sys.stdin); assert d.get('drag_ended')=='cancel', d.get('drag_ended'); print(' cancelled drag -> cancel')" \ + || fail "cancelled drag did not end 'cancel': $(echo "$R2" | head -c 160)" +# Content drag inside a pane is a TEXT SELECTION (pane-carry needs hold-to-wisp, +# which ctl drag cannot express); pane count must not change. +R3=$("$BIN" ctl drag $((W/2)) $((H/2)) $((W-10)) $((H/2)) --steps 14 --paced 16 2>&1) +ENDED=$(echo "$R3" | jq_py "import json,sys; print(json.load(sys.stdin).get('drag_ended','parse-fail'))" 2>/dev/null || echo parse-fail) +PANES_AFTER=$(st | jq_py "import json,sys; print(len(json.load(sys.stdin)['state']['panes']))") +{ [ "$ENDED" = "selection" ] && [ "$PANES_AFTER" = "$PANES_BEFORE" ]; } \ + && echo " PASS: content drag is selection, panes stable ($PANES_BEFORE)" \ + || fail "content drag ended '$ENDED', panes $PANES_BEFORE -> $PANES_AFTER" + +echo "=== SMOKE 6: stability — idle soak, still alive, log clean of panics ===" +sleep "${EMBER_SMOKE_SOAK:-30}" +kill -0 "$APP" 2>/dev/null || fail "app died during idle soak" +PANICS=$(grep -icE 'panic|thread .* panicked' /tmp/ember-smoke.log || true) +[ "$PANICS" = "0" ] && echo " PASS: alive after soak, no panics" || fail "$PANICS panic line(s) in app log" + +kill "$APP" 2>/dev/null || true +echo "WINDOW_SMOKE_DONE" diff --git a/scripts/smoke/x11-container-smoke.sh b/scripts/smoke/x11-container-smoke.sh index 6da423d..04914f4 100755 --- a/scripts/smoke/x11-container-smoke.sh +++ b/scripts/smoke/x11-container-smoke.sh @@ -1,27 +1,39 @@ #!/usr/bin/env bash -# X11 smoke test of current main (multi-window + drag/wisp era) inside -# ubuntu:24.04: build, start Xvfb, run the REAL WINDOWED app on X11 with -# software Vulkan, then drive + introspect it via the ctl socket. +# Standalone X11 real-window smoke of ember-term inside ubuntu:24.04: install +# deps, build, start Xvfb + software Vulkan, then run the SHARED smoke +# assertions (scripts/smoke/window-smoke.sh) — the same ones the CI gate runs, +# so the container prototype and CI can never drift. +# +# docker run --rm -v "$PWD/out:/out" ubuntu:24.04 \ +# bash -c 'apt-get update && apt-get install -y git && +# git clone --depth 1 https://github.com/kingb/ember /src && +# bash /src/scripts/smoke/x11-container-smoke.sh' +# +# Or point it at an already-checked-out tree by setting EMBER_SRC=/path. set -e export DEBIAN_FRONTEND=noninteractive -echo "=== apt deps (build + X11 runtime incl. libxi6 + xvfb) ===" +SRC="${EMBER_SRC:-/src}" + +echo "=== apt deps (build + X11 runtime incl. libxcursor1/libxkbcommon-x11-0 + xvfb) ===" apt-get update -qq apt-get install -y -qq \ curl ca-certificates git build-essential pkg-config \ libwayland-dev libxkbcommon-dev libx11-dev libxcursor-dev libxi-dev \ libxrandr-dev libxcb1-dev libvulkan-dev mesa-vulkan-drivers vulkan-tools \ - libfontconfig1-dev fonts-dejavu-core zsh \ + libfontconfig1-dev fonts-dejavu-core zsh python3 \ xvfb x11-apps imagemagick libxi6 libxcursor1 libxkbcommon-x11-0 >/dev/null 2>&1 echo " ok" -echo "=== rust + clone + build main ===" + +echo "=== rust + (clone if needed) + build ===" curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal >/dev/null 2>&1 . "$HOME/.cargo/env" -git clone -q --depth 1 https://github.com/kingb/ember /src -cd /src -echo " at $(git rev-parse --short HEAD)" +if [ ! -d "$SRC" ]; then + git clone -q --depth 1 https://github.com/kingb/ember "$SRC" +fi +cd "$SRC" +echo " at $(git rev-parse --short HEAD 2>/dev/null || echo '')" export CARGO_BUILD_JOBS=4 cargo build --release -p ember-app --bin ember-term 2>&1 | tail -1 -BIN=/src/target/release/ember-term echo "=== X11 display (Xvfb) + software Vulkan ===" Xvfb :99 -screen 0 1280x800x24 >/dev/null 2>&1 & @@ -31,56 +43,7 @@ export VK_ICD_FILENAMES="$(ls /usr/share/vulkan/icd.d/lvp_icd*.json | head -1)" export WGPU_BACKEND=vulkan export WINIT_UNIX_BACKEND=x11 -echo "=== SMOKE 1: windowed app launches on X11 ===" -EMBER_CONTROL=1 "$BIN" >/tmp/app.log 2>&1 & -APP=$! -sleep 8 -kill -0 $APP 2>/dev/null && echo " PASS: app alive after 8s (pid $APP)" || { echo " FAIL: app died"; cat /tmp/app.log | tail -20; exit 1; } - -echo "=== SMOKE 2: ctl reaches it + grid state sane ===" -"$BIN" ctl state > /tmp/state1.json 2>&1 && echo " PASS: ctl state ok" || { echo " FAIL: ctl"; cat /tmp/state1.json; } -head -c 400 /tmp/state1.json; echo - -echo "=== SMOKE 3: typed input round-trips through the X11 shell ===" -"$BIN" ctl type 'echo x11-smoke-$((6*7))' >/dev/null -"$BIN" ctl key Enter >/dev/null -sleep 2 -if "$BIN" ctl state | grep -q "x11-smoke-42"; then echo " PASS: shell echoed x11-smoke-42"; else echo " FAIL: output not found"; fi - -echo "=== SMOKE 4: visual evidence (X11 root capture) ===" -xwd -root -silent | convert xwd:- /out/x11-smoke.png 2>/dev/null && echo " captured /out/x11-smoke.png" - -echo "=== SMOKE 4b: drag/carry on X11 (tab reorder, pane split-by-drag, cancel) ===" -# Second tab so there's something to reorder. -"$BIN" ctl chord cmd+t >/dev/null; sleep 1 -TABS_BEFORE=$("$BIN" ctl state | python3 -c "import json,sys; print(len(json.load(sys.stdin)['state']['tabs']))") -echo " tabs open: $TABS_BEFORE (want 2)" -# Surface dims drive the coordinates (strip along the top). -read -r W H <<<"$("$BIN" ctl state | python3 -c "import json,sys; s=json.load(sys.stdin)['state']['surface']; print(s[0], s[1])")" -# Reorder: drag tab 1's chip toward tab 2's slot. -R1=$("$BIN" ctl drag 40 12 160 12 --steps 12 --paced 16 2>&1) -echo " reorder drag -> $(echo "$R1" | python3 -c "import json,sys; print(json.load(sys.stdin).get('drag_ended','parse-fail'))" 2>/dev/null || echo "$R1" | head -c 120)" -# Cancelled drag: must end 'cancel' and change nothing. -PANES_BEFORE=$("$BIN" ctl state | python3 -c "import json,sys; print(len(json.load(sys.stdin)['state']['panes']))") -R2=$("$BIN" ctl drag $((W/2)) $((H/2)) $((W-30)) $((H/2)) --steps 10 --cancel 2>&1) -echo " cancelled drag -> $(echo "$R2" | python3 -c "import json,sys; print(json.load(sys.stdin).get('drag_ended','parse-fail'))" 2>/dev/null || echo "$R2" | head -c 120)" -# Content drag across the pane: must be TEXT SELECTION (a plain click-drag -# inside pane content selects; pane-carry requires the hold-to-wisp gesture, -# which ctl drag cannot express yet — needs a --hold-ms arg, flagged upstream). -R3=$("$BIN" ctl drag $((W/2)) $((H/2)) $((W-10)) $((H/2)) --steps 14 --paced 16 2>&1) -ENDED=$(echo "$R3" | python3 -c "import json,sys; print(json.load(sys.stdin).get('drag_ended','parse-fail'))" 2>/dev/null || echo parse-fail) -PANES_AFTER=$("$BIN" ctl state | python3 -c "import json,sys; print(len(json.load(sys.stdin)['state']['panes']))") -if [ "$ENDED" = "selection" ] && [ "$PANES_AFTER" = "$PANES_BEFORE" ]; then - echo " PASS: content drag is selection, pane count stable ($ENDED)" -else - echo " FAIL: content drag ended '$ENDED', panes $PANES_BEFORE -> $PANES_AFTER" -fi -xwd -root -silent | convert xwd:- /out/x11-drag.png 2>/dev/null && echo " captured /out/x11-drag.png" - -echo "=== SMOKE 5: stability — 30s idle, still alive, log clean ===" -sleep 30 -kill -0 $APP 2>/dev/null && echo " PASS: alive after idle" || echo " FAIL: died during idle" -grep -icE "panic|error" /tmp/app.log | xargs echo " panic/error lines in app log:" -tail -5 /tmp/app.log | sed 's/^/ log: /' -kill $APP 2>/dev/null || true -echo "X11_SMOKE_DONE" +# Delegate to the shared assertions (also run by .github/workflows/ci.yml). +export EMBER_SMOKE_BIN="$SRC/target/release/ember-term" +export EMBER_SMOKE_OUT="${EMBER_SMOKE_OUT:-/out}" +exec bash "$SRC/scripts/smoke/window-smoke.sh"