Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions scripts/smoke/window-smoke.sh
Original file line number Diff line number Diff line change
@@ -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"
89 changes: 26 additions & 63 deletions scripts/smoke/x11-container-smoke.sh
Original file line number Diff line number Diff line change
@@ -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 '<local>')"
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 &
Expand All @@ -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"
Loading