#!/usr/bin/env bash # End-to-end test of dingger's status model against a real, isolated tmux # server. Nothing here touches the user's tmux server or hook socket. # # tests/e2e.sh [path/to/dingger] # # The test daemon polls only every 30s, so every update asserted here must # arrive through events (hook reports, focus pokes, tmux focus hooks), the # same paths that make the real UI react immediately. # # Layout: # session a: window wa1 (pane A1, fake pi), window wa2 (pane A2, fake pi) # session b: window wb1 (pane B1, fake pi) # session viewer: a pane running a real tmux client attached to session b, # so "what the user is looking at" is real tmux state. set -u ROOT=$(cd "$(dirname "$0")/.." && pwd) DINGGER="${1:-$ROOT/target/release/dingger}" MENUBAR="${2:-$ROOT/target/dingger-menu}" # tmux hooks run these from other directories: make them absolute. case "$DINGGER" in /*) ;; *) DINGGER="$PWD/$DINGGER" ;; esac case "$MENUBAR" in /*) ;; *) MENUBAR="$PWD/$MENUBAR" ;; esac SOCK=dingger-e2e DIR=$(mktemp -d /tmp/dingger-e2e.XXXXXX) HOOK="$DIR/agent.sock" FAILS=0 PASSES=0 # Every dingger invocation below (daemon, focus, poke, menu bar) targets the # isolated server and the test daemon's hook socket. export DINGGER_TMUX_SOCKET="$SOCK" DINGGER_AGENT_SOCKET="$HOOK" t() { tmux -u -L "$SOCK" "$@"; } cleanup() { [ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null t kill-server 2>/dev/null rm -rf "$DIR" } trap cleanup EXIT # Notifications are on, delivered by a fake notifier that takes 3s like the # real one can; the UI must not wait for it. NOTES="$DIR/notifications" start_daemon() { DINGGER_NOTIFIER="sleep 3; echo \"\$0|\$1\" >> $NOTES" RUST_LOG=debug \ "$DINGGER" watch --interval-secs 30 --no-sounds >>"$DIR/daemon.log" 2>&1 & DAEMON_PID=$! for _ in $(seq 50); do [ -S "$HOOK" ] && return; sleep 0.1; done echo "daemon did not start"; cat "$DIR/daemon.log"; exit 1 } hook() { # pane state python3 - "$HOOK" "$1" "$2" <<'PY' import json, socket, sys s = socket.socket(socket.AF_UNIX) s.connect(sys.argv[1]) s.sendall((json.dumps({"method": "pane.report_agent", "params": {"pane_id": sys.argv[2], "agent": "pi", "state": sys.argv[3]}}) + "\n").encode()) s.close() PY } opt() { t show -pv -t "$1" "$2" 2>/dev/null; } # pane option gopt() { t show -gv "$1" 2>/dev/null; } # global option client_window() { t display -p -c "$CLIENT" '#{window_name}'; } pass() { PASSES=$((PASSES + 1)); echo " ok $1"; } fail() { FAILS=$((FAILS + 1)); echo " FAIL $1"; } expect() { # description command expected [tries] (polls every 0.1s, default 4s) local desc=$1 cmd=$2 want=$3 tries=${4:-40} got for _ in $(seq "$tries"); do got=$(eval "$cmd") [ "$got" = "$want" ] && { pass "$desc"; return; } sleep 0.1 done fail "$desc: want [$want] got [$got]" } # --- setup ------------------------------------------------------------------- mkdir -p "$DIR/bin" printf '#!/bin/sh\nexec sleep 100000\n' >"$DIR/bin/pi" chmod +x "$DIR/bin/pi" t -f /dev/null new-session -d -s a -n wa1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi" t new-window -d -t a: -n wa2 -c "$DIR" "$DIR/bin/pi" t new-session -d -s b -n wb1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi" t new-session -d -s c -n wc1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi" t set -g mouse on A1=$(t display -p -t a:wa1 '#{pane_id}') A2=$(t display -p -t a:wa2 '#{pane_id}') B1=$(t display -p -t b:wb1 '#{pane_id}') C1=$(t display -p -t c:wc1 '#{pane_id}') # pi titles its pane "π - - ", or "π - " D=$(basename "$DIR") t select-pane -t "$A1" -T "π - Deploy the widget - $D" t select-pane -t "$A2" -T "π - $D" t select-pane -t "$B1" -T "π - Review PR 42 - $D" t new-session -d -s viewer -x 120 -y 40 "env -u TMUX tmux -u -L $SOCK attach -t b" for _ in $(seq 50); do CLIENT=$(t list-clients -F '#{client_name}' | head -1); [ -n "$CLIENT" ] && break; sleep 0.1; done [ -z "$CLIENT" ] && { echo "no client attached"; exit 1; } echo "panes A1=$A1 A2=$A2 B1=$B1, client $CLIENT on $(client_window)" # tmux focus hooks, as in packaging/tmux/dingger.tmux, pointed at the test daemon POKE="run-shell -b \"DINGGER_AGENT_SOCKET=$HOOK $DINGGER poke\"" [ -z "${E2E_NO_FOCUS_HOOKS:-}" ] && for h in client-session-changed session-window-changed window-pane-changed; do t set-hook -g "$h[77]" "$POKE"; done start_daemon # --- 1. first observation is idle, not done (herdr: initial idle stays seen) -- echo "1. first observation" for p in "$A1" "$A2" "$B1" "$C1"; do hook "$p" idle; done expect "A1 idle on first sight" "opt $A1 @agent_state" idle expect "A2 idle on first sight" "opt $A2 @agent_state" idle expect "B1 idle on first sight" "opt $B1 @agent_state" idle expect "queue empty" "gopt @dingger_queue" "" expect "A1 name from pi's title" "opt $A1 @agent_name" "Deploy the widget" expect "A2 unnamed pi session has no name" "opt $A2 @agent_name" "" expect "B1 name from pi's title" "opt $B1 @agent_name" "Review PR 42" # --- 2. working, then completion away from the window -> done ---------------- echo "2. completion while away" hook "$A1" working expect "A1 working within 0.5s (event-driven)" "opt $A1 @agent_state" working 5 expect "working count 1" "gopt @dingger_working" 1 hook "$A1" idle expect "A1 done within 0.5s despite a 3s notifier" "opt $A1 @agent_state" done 5 expect "done count 1" "gopt @dingger_done" 1 expect "queue = A1" "gopt @dingger_queue" "$A1" expect "'pi finished' notification for a:wa1" "grep -c 'pi finished|a:wa1 (pi)' $NOTES 2>/dev/null" 1 60 # --- 3. completion in the window the user is looking at -> idle -------------- echo "3. completion while looking" hook "$B1" working expect "B1 working" "opt $B1 @agent_state" working hook "$B1" idle expect "B1 idle (user is looking at wb1)" "opt $B1 @agent_state" idle expect "queue still = A1" "gopt @dingger_queue" "$A1" sleep 3.5 expect "no notification for a completion the user watched" "grep -c 'b:wb1' $NOTES" 0 # --- 4. FIFO and blocked ------------------------------------------------------- echo "4. fifo and blocked" hook "$A2" working hook "$A2" blocked expect "A2 blocked within 0.5s" "opt $A2 @agent_state" blocked 5 expect "blocked count 1" "gopt @dingger_blocked" 1 expect "'pi needs attention' notification for a:wa2" "grep -c 'pi needs attention|a:wa2 (pi)' $NOTES 2>/dev/null" 1 60 expect "queue = A2,A1 (blocked before done)" "gopt @dingger_queue" "$A2,$A1" hook "$C1" working; hook "$C1" idle expect "C1 done" "opt $C1 @agent_state" done expect "queue = A2,A1,C1 (oldest done first)" "gopt @dingger_queue" "$A2,$A1,$C1" DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$C1" expect "client shows wc1" "client_window" wc1 expect "C1 idle after the jump" "opt $C1 @agent_state" idle # --- 5. attending by navigating --------------------------------------------- echo "5. navigate to a done agent" t switch-client -c "$CLIENT" -t "$A1" expect "client shows wa1" "client_window" wa1 expect "A1 idle within 1s of looking (tmux focus hook)" "opt $A1 @agent_state" idle 10 expect "queue = A2" "gopt @dingger_queue" "$A2" expect "done count 0" "gopt @dingger_done" 0 # --- 6. status-line click: focus with no args jumps to the queue head -------- echo "6. dingger focus (status-line click)" DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus expect "client jumped to wa2" "client_window" wa2 expect "A2 still blocked (needs input even when seen)" "opt $A2 @agent_state" blocked expect "ack consumed" "opt $A2 @dingger_ack" "" hook "$A2" working hook "$A2" idle expect "A2 idle (completed while looked at)" "opt $A2 @agent_state" idle expect "queue empty" "gopt @dingger_queue" "" # --- 7. menu row click: focus --pane ----------------------------------------- echo "7. dingger focus --pane (menu row click)" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A1" working hook "$A1" idle expect "A1 done again" "opt $A1 @agent_state" done DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A1" expect "client shows wa1" "client_window" wa1 expect "A1 idle after the jump" "opt $A1 @agent_state" idle # --- 8. a whole turn between two polls is still a completion ---------------- echo "8. sub-second turn" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A1" working; hook "$A1" idle # both within one poll interval expect "A1 done after a sub-second turn" "opt $A1 @agent_state" done t switch-client -c "$CLIENT" -t "$A1" expect "A1 idle once looked at" "opt $A1 @agent_state" idle # --- 9. restart keeps state ----------------------------------------------------- echo "9. daemon restart" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A2" working hook "$A2" idle expect "A2 done before restart" "opt $A2 @agent_state" done kill "$DAEMON_PID"; wait "$DAEMON_PID" 2>/dev/null start_daemon sleep 1.5 expect "A2 still done after restart" "opt $A2 @agent_state" done expect "A1 still idle after restart" "opt $A1 @agent_state" idle expect "B1 still idle after restart" "opt $B1 @agent_state" idle expect "queue = A2 after restart" "gopt @dingger_queue" "$A2" hook "$A2" working expect "hook state still authoritative after restart" "opt $A2 @agent_state" working # --- 10. the tmux status-line segment and its click binding ----------------- echo "10. status line" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A2" blocked hook "$A1" working; hook "$A1" idle expect "A2 blocked" "opt $A2 @agent_state" blocked expect "A1 done" "opt $A1 @agent_state" done t source-file "$ROOT/packaging/tmux/dingger.tmux" segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g') case "$segment" in *"×1"*"✓1"*) pass "segment shows ×1 ✓1 ($segment)";; *) fail "segment: [$segment]";; esac expect "mouse binding installed" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'dingger focus'" 1 # same run-shell + #{client_name} expansion as the mouse binding, via a key t bind -n F12 run-shell "DINGGER_TMUX_SOCKET=$SOCK $DINGGER focus --client '#{client_name}'" t send-keys -t viewer: F12 expect "key binding jumped to the top waiter (A2, blocked)" "client_window" wa2 t send-keys -t viewer: F12 expect "next press jumps to the next waiter (A1)" "client_window" wa1 expect "A1 idle once jumped to" "opt $A1 @agent_state" idle # --- 11. the menu bar binary: rendering and row click ------------------------ echo "11. menu bar" if [ -x "$MENUBAR" ]; then mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; } t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A1" working; hook "$A1" idle expect "A1 done" "opt $A1 @agent_state" done expect "menu title" "mb --print | head -1" "title: ×1 ✓1" expect "menu rows: herdr priority, names as titles, harness when unnamed" \ "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;" expect "subtitle: location, harness, waiting time" "mb --print | grep 'Deploy the widget' | cut -f3" "a · wa1 · pi · done now" expect "idle subtitle has no waiting time" "mb --print | grep 'Review PR 42' | cut -f3" "b · wb1 · pi" mb --click "$A1" expect "row click jumped to wa1" "client_window" wa1 expect "row click marked A1 idle" "opt $A1 @agent_state" idle expect "menu title after the click" "mb --print | head -1" "title: ×1" else fail "menu bar binary not found at $MENUBAR" fi # --- 12. the shipped pi extension reports for its own pane ------------------- echo "12. pi extension" cat >"$DIR/ext-harness.mjs" <<'JS' // Loads the shipped extension with a fake pi and fires lifecycle events. const ext = await import(process.env.EXTENSION); const on = {}; ext.default({ on: (name, fn) => (on[name] = fn), events: { on: () => {} } }); const ctx = { mode: "tui", isIdle: () => true }; for (const event of process.argv.slice(2)) { await on[event]?.({ type: event, reason: "reload" }, ctx); await new Promise((r) => setTimeout(r, 300)); } JS ext() { # pane events... (run "inside" a pane: tmux sets TMUX_PANE there) local pane=$1; shift TMUX_PANE="$pane" EXTENSION="$ROOT/crates/dinggerd/assets/pi/pi-agent-state.ts" \ node --experimental-strip-types --no-warnings "$DIR/ext-harness.mjs" "$@" } if command -v node >/dev/null; then # A2 is not the active pane of session a (wa1 is the active window), the # case where asking tmux for "the current pane" picks the wrong pane. [ "$(t display -p -t a: '#{pane_id}')" != "$A2" ] && pass "A2 is not session a's active pane" || fail "test setup: A2 is active" before_a1=$(opt "$A1" @agent_state) ext "$A2" session_start agent_start expect "extension report lands on A2 (working)" "opt $A2 @agent_state" working expect "A1 untouched" "opt $A1 @agent_state" "$before_a1" ext "$A2" agent_settled expect "extension completion away from the window: A2 done" "opt $A2 @agent_state" done else fail "node not found; cannot test the pi extension" fi # --- 13. the floating panel: visibility rules and compact content ----------- echo "13. floating panel" if [ -x "$MENUBAR" ]; then mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; } expect "panel visibility rules (selftest)" "mb --selftest | tail -1" "selftest ok" expect "panel lists the waiting agent" "mb --print-panel | head -1 | cut -f1,2 | tr '\t' '|'" "✓ done|pi" expect "panel summarizes the rest" "mb --print-panel | tail -1" "○ 3 idle" DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A2" expect "A2 idle after the jump" "opt $A2 @agent_state" idle expect "panel with nothing waiting" "mb --print-panel" "Nothing waiting · ○ 4 idle" expect "full list: every agent, then the summary with the toggle" \ "mb --print-panel --full | cut -f1,2 | tr '\t\n' '|;'" \ "○ idle|Deploy the widget;○ idle|pi;○ idle|Review PR 42;○ idle|pi;Nothing waiting · ○ 4 idle|[Show less ▴];" expect "compact in Always mode offers 'Show all'" "mb --print-panel --toggle" "Nothing waiting · ○ 4 idle [Show all ▾]" hook "$A1" working; hook "$A1" idle expect "A1 done" "opt $A1 @agent_state" done expect "full list separates waiting from the rest" "mb --print-panel --full | sed -n 2p" "---" else fail "menu bar binary not found at $MENUBAR" fi # --- 14. clicks work with launchd's environment ------------------------------ # launchd starts the menu bar app with PATH=/usr/bin:/bin:/usr/sbin:/sbin, # which has no Homebrew tmux; every click runs `dingger focus` from there. echo "14. launchd environment" launchd_env() { env -i HOME="$HOME" PATH=/usr/bin:/bin:/usr/sbin:/sbin DINGGER_TMUX_SOCKET="$SOCK" \ DINGGER_AGENT_SOCKET="$HOOK" DINGGER_BIN="$DINGGER" "$@" } t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A1" working; hook "$A1" idle expect "A1 done" "opt $A1 @agent_state" done if [ -x "$MENUBAR" ]; then launchd_env "$MENUBAR" --click "$A1" 2>"$DIR/click.err" expect "menu bar row click jumps, under launchd's PATH" "client_window" wa1 expect "A1 idle after that click" "opt $A1 @agent_state" idle fi t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A2" working; hook "$A2" idle expect "A2 done" "opt $A2 @agent_state" done launchd_env "$DINGGER" focus expect "dingger focus (jump shortcut) jumps, under launchd's PATH" "client_window" wa2 # --- 15. agent search ---------------------------------------------------------- echo "15. agent search" if [ -x "$MENUBAR" ]; then mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; } expect "empty query lists every agent" "mb --print-search '' | tail -1 | cut -d' ' -f1,2" "4 agents" expect "fuzzy name match, selected" "mb --print-search 'rvw42' | head -1 | cut -f1,2" "› ○ idle Review PR 42" expect "every word must match" "mb --print-search 'deploy 42' | head -1" "No agents match 'deploy 42'" expect "finds an agent by its window" "mb --print-search wc1 | head -1 | cut -f3" "c · wc1 · pi" expect "finds agents by directory" "mb --print-search \"$(basename "$DIR")\" | tail -1 | cut -d' ' -f1,2" "4 agents" hook "$A2" working expect "status word ranks first" "mb --print-search working | head -1 | cut -f1" "› ◐ working" else fail "menu bar binary not found at $MENUBAR" fi echo echo "$PASSES passed, $FAILS failed" [ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; }