aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-20 12:43:07 +0300
committerIgor Soarez <igor@soarez.org>2026-10-20 12:43:07 +0300
commit4af6a4f625b1965b783c9efb857f8ac665adb8eb (patch)
treedd2dac9627735fd02b3372a4f569125a702ba57a /tests
parent943fc940d9c387916708cb3fdc912323c92ef6dc (diff)
rebuild the status model as herdr's state machine, event-driven and tested
the seen logic was inverted: first observations were marked done and real completions idle, so the menu showed teal for agents that did nothing and green for agents that finished, and attending changed nothing visible. - watcher::advance is herdr's apply_pane_state_change + mark_active_tab_seen as a pure function, unit-tested against herdr's own cases; attendance is window-level like herdr's active tab - hook reports are events applied in order, so a turn shorter than a poll is still a completion - event-driven: hook reports, dingger focus, and tmux focus hooks wake the watcher; notifications are delivered off-thread (the macos call blocked ticks for 2+ seconds) after the pane options are written - all per-pane state persists in tmux pane options, so restarts and deploys lose nothing, including the authoritative hook state - every click goes through dingger focus: the menu bar shells out to it and the status-line binding runs it; it picks the right client, skips the window already shown, and activates the terminal app hosting the client - the ready queue is ordered like the menu (blocked first, then oldest done) - menu bar and tmux segment render herdr's symbols in herdr's palette - tests/e2e.sh: 60 assertions against an isolated tmux server with a real client, a 30s poll interval, and a slow fake notifier
Diffstat (limited to 'tests')
-rwxr-xr-xtests/e2e.sh244
1 files changed, 244 insertions, 0 deletions
diff --git a/tests/e2e.sh b/tests/e2e.sh
new file mode 100755
index 0000000..d74d73b
--- /dev/null
+++ b/tests/e2e.sh
@@ -0,0 +1,244 @@
+#!/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}"
+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 -x 120 -y 40 "$DIR/bin/pi"
+t new-window -d -t a: -n wa2 "$DIR/bin/pi"
+t new-session -d -s b -n wb1 -x 120 -y 40 "$DIR/bin/pi"
+t new-session -d -s c -n wc1 -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}')
+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" ""
+
+# --- 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 in herdr priority order" "mb --print | tail -n +2 | cut -d' ' -f2,4 | tr '\n' ' '" "blocked a:wa2 done a:wa1 idle b:wb1 idle c:wc1 "
+ 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
+
+echo
+echo "$PASSES passed, $FAILS failed"
+[ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; }