From 4af6a4f625b1965b783c9efb857f8ac665adb8eb Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Tue, 20 Oct 2026 12:43:07 +0300 Subject: 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 --- tests/e2e.sh | 244 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100755 tests/e2e.sh (limited to 'tests') 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; } -- cgit v1.2.3