tests/e2e.sh (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 |
#!/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" DINGGER_STATE_DIR="$DIR/state"
t() { tmux -u -L "$SOCK" "$@"; }
cleanup() {
[ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null
[ -n "${DAEMON2_PID:-}" ] && kill "$DAEMON2_PID" 2>/dev/null
t kill-server 2>/dev/null
tmux -L "$SOCK-restore-$$" 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 "π - <session name> - <cwd basename>", or "π - <dir>"
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
expect "clicking the restore offer runs restore" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'restore --report'" 1
t set -g @dingger_restore 3
segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g')
case "$segment" in "↺3 "*"×1"*) pass "segment offers to restore 3 sessions ($segment)";; *) fail "restore segment: [$segment]";; esac
t set -gu @dingger_restore
# 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
# --- 16. restore after the tmux server goes away ------------------------------
# A second server and daemon. "viewer" hosts a client the way a terminal
# does: Ghostty runs `tmux new-session -As 0`.
echo "16. restore"
R="$SOCK-restore-$$"
STATE2="$DIR/state2"
# tmux gives new windows the environment of the client that creates them:
# both the test and restore run with the fake pi first on PATH.
r() { env PATH="$DIR/rbin:$PATH" tmux -u -L "$R" "$@"; }
# A server restore starts gets the same plain shells as the test's own: the
# user's login shell would read their profile, which puts the real agents
# first on PATH.
printf 'set -g default-shell /bin/sh\nset -g default-command /bin/sh\n' >"$DIR/tmux-test.conf"
renv() { env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \
DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG="$DIR/tmux-test.conf" "$@"; }
# Every pi or claude in a test pane must be a fake (they run from $DIR).
real_agents() {
for tty in $(r list-panes -a -F '#{pane_tty}' 2>/dev/null | sed 's|/dev/||'); do
ps -o comm=,args= -t "$tty" | awk -v dir="$DIR/" '($1 ~ /(^|\/)(pi|claude)$/ || $2 ~ /^(pi|claude)$/) && index($2, dir) != 1'
done
}
W="$DIR/w"; mkdir -p "$W/one" "$W/two" "$W/three"
# A fake pi named pi (the daemon finds agents by process name) that records
# its arguments.
mkdir -p "$DIR/rbin/real"
cp /bin/sleep "$DIR/rbin/real/pi" && codesign --force --sign - "$DIR/rbin/real/pi" 2>/dev/null
printf '#!/bin/sh\necho "$*" >> %s\nexec %s 100000\n' "$DIR/pi-args" "$DIR/rbin/real/pi" >"$DIR/rbin/pi"
chmod +x "$DIR/rbin/pi"
start_viewer() {
env PATH="$DIR/rbin:$PATH" tmux -u -L "$R" -f /dev/null new-session -d -s viewer -x 120 -y 40 \; set -g default-shell /bin/sh \; \
set -g default-command /bin/sh \; \
send-keys -t viewer "exec env -u TMUX tmux -u -L $R new-session -As 0 -c $W/one" Enter
for _ in $(seq 50); do r has-session -t =0 2>/dev/null && [ -n "$(r list-clients)" ] && return; sleep 0.1; done
echo "viewer client did not attach"
}
start_viewer
P0=$(r display -p -t '=0:' '#{pane_id}')
r rename-window -t "$P0" edit
P1=$(r split-window -h -t "$P0" -c "$W/two" -P -F '#{pane_id}')
r split-window -v -t "$P1" -c "$W/three"
r resize-pane -t "$P0" -x 30
r new-window -d -t '=0:5' -c "$W/two"
r split-window -d -t '=0:5' -c "$W/three"
r resize-pane -Z -t '=0:5.1'
r select-window -t '=0:edit'
r new-session -d -s work -n grid -c "$W/three" -x 100 -y 30
for d in one two three; do r split-window -d -t '=work:grid' -c "$W/$d"; r select-layout -t '=work:grid' tiled; done
r select-pane -t '=work:grid.2'
r new-window -d -t '=work:7' -n logs -c "$W/one"
r select-window -t '=work:7'
r new-session -d -s keep -n saved -c "$W/one"
r new-window -d -t '=keep:' -n saved2
# Agents, a running command, and a command the zsh hook recorded.
r new-window -d -t '=work:3' -n agent -c "$W/two"
r new-window -d -t '=work:4' -n gone -c "$W/one"
r new-window -d -t '=work:5' -n cmd -c "$W/one"
r new-window -d -t '=work:6' -n hooked -c "$W/one"
# Never start the real pi: the panes must find the fake one first.
r send-keys -t '=work:agent' "command -v pi > $DIR/which-pi" Enter
for _ in $(seq 30); do [ -s "$DIR/which-pi" ] && break; sleep 0.1; done
if [ "$(cat "$DIR/which-pi" 2>/dev/null)" != "$DIR/rbin/pi" ]; then
fail "the restore server's panes would run $(cat "$DIR/which-pi" 2>/dev/null), not the fake pi; stopping"
echo "$PASSES passed, $FAILS failed"; exit 1
fi
r send-keys -t '=work:agent' 'clear; pi' Enter
r send-keys -t '=work:gone' 'pi' Enter
r send-keys -t '=work:cmd' "sh -c 'sleep 100000; :' 'two words'" Enter
r send-keys -t '=work:hooked' 'sleep 200000' Enter
fingerprint() {
r list-panes -a -F '#{session_name}|#{window_index}|#{?automatic-rename,(auto),#{window_name}}|#{window_active}|#{window_zoomed_flag}|#{automatic-rename}|#{pane_index}|#{pane_active}|#{pane_left},#{pane_top},#{pane_width}x#{pane_height}|#{pane_current_path}' \
| grep -E '^(0|work)\|'
}
BEFORE=$(fingerprint)
# Started directly, not through a function: $! must be the daemon itself.
env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \
DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG="$DIR/tmux-test.conf" \
"$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >>"$DIR/daemon2.log" 2>&1 &
DAEMON2_PID=$!
latest_snapshot() { ls "$STATE2"/snapshots/*.json 2>/dev/null | tail -1; }
expect "a snapshot of both sessions" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\" 2>/dev/null" 1 80
hook2() { # pane state session-file
python3 - "$DIR/hook2.sock" "$1" "$2" "$3" <<'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], "agent_session_path": sys.argv[4]}}) + "\n").encode())
s.close()
PY
}
AGENT=$(r display -p -t '=work:agent' '#{pane_id}')
GONE=$(r display -p -t '=work:gone' '#{pane_id}')
expect "the fake pi agents are detected" "r show -pv -t $AGENT @agent 2>/dev/null; r show -pv -t $GONE @agent 2>/dev/null" "pi
pi" 80
# Session files exist: restore resumes only sessions with one.
mkdir -p "$W/sessions" && touch "$W/sessions/a b.jsonl" "$W/sessions/gone.jsonl"
hook2 "$AGENT" working "$W/sessions/a b.jsonl"
hook2 "$GONE" idle "$W/sessions/gone.jsonl"
r set-option -p -t '=work:hooked' @dingger_cmd 'slp 200000 | cat'
expect "an agent's session file is kept on its pane" "r show -pv -t $AGENT @agent_session 2>/dev/null" "$W/sessions/a b.jsonl"
in_snapshot() { python3 -c 'import sys; print(sys.argv[2] in open(sys.argv[1]).read())' "$(latest_snapshot)" "$1"; }
expect "snapshot: the agent's session" "in_snapshot '\"session\": \"$W/sessions/a b.jsonl\"'" True 80
expect "snapshot: a running command's exact arguments" "in_snapshot \"'sleep 100000; :' 'two words'\"" True 80
expect "snapshot: the zsh hook's text" "in_snapshot '\"command\": \"slp 200000 | cat\"'" True 80
# An agent that exits just before tmux dies (a shutdown stops programs first).
kill "$(ps -o tpgid= -p "$(r display -p -t "$GONE" '#{pane_pid}')" | tr -d ' ')"
expect "the exited agent is noticed" "r show -pv -t $GONE @agent 2>/dev/null" ""
sleep 1.5
expect "snapshot: the exited agent is kept for a while" "in_snapshot gone.jsonl" True
r kill-server
expect "the dead server's layout becomes the restore candidate" \
"python3 -c 'import json,sys; c=json.load(open(sys.argv[1])); print(c[\"status\"], len(c[\"snapshot\"][\"sessions\"]))' $STATE2/restore.json 2>/dev/null" "offered 4" 80
expect "the last snapshot is kept while tmux is down" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\"" 1
start_viewer
r new-session -d -s keep -n mine "sleep 100000"
expect "the new server has a bare 0" "r list-windows -t =0 -F '#{window_panes}' | tr '\n' ' '" "1 "
expect "the candidate survives the new server's snapshots" \
"python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"snapshot\"][\"server\"] != json.load(open(sys.argv[2]))[\"server\"])' $STATE2/restore.json \"\$(latest_snapshot)\"" True 80
expect "dry run plans: replace 0, keep keep and viewer, create work" \
"renv \"$DINGGER\" restore --dry-run | grep -v '^ ' | cut -c1-14 | sort | tr '\n' ';'" "create work:;keep keep:;keep viewe;replace 0: 2 ;"
mbr() { renv DINGGER_BIN="$DINGGER" DINGGER_RESTORE_DIRECT=1 \
DINGGER_NOTIFIER="echo \"\$0|\$1\" >> $DIR/notes2" "$MENUBAR" "$@"; }
expect "the daemon offers to restore 0 and work" \
"python3 -c 'import json,sys; o=json.load(open(sys.argv[1])); print([s[\"name\"] for s in o[\"sessions\"]], o[\"agents\"], o[\"interrupted\"])' $STATE2/offer.json 2>/dev/null" \
"['0', 'work'] 2 1" 80
expect "and tells the status line" "r show -gv @dingger_restore" 2
expect "the panel offers it first" "mbr --print-panel | head -1 | cut -f1,3" "↺ Restore 2 sessions · 2 agents [Not now]"
expect "with what was interrupted" "mbr --print-panel | head -1 | cut -f2 | grep -o '1 was mid-turn'" "1 was mid-turn"
mbr --restore
cp "$STATE2/restore.log" "$DIR/restore.out" 2>/dev/null
expect "restore succeeds" "grep -c FAILED $DIR/restore.out" 0
expect "agents resumed with their session files" "grep -- --session $DIR/pi-args | sort" "--session $W/sessions/a b.jsonl
--session $W/sessions/gone.jsonl"
expect "no real agent runs in a test pane" "real_agents" ""
expect "both agents run" "r display -p -t '=work:agent' '#{pane_current_command}'; r display -p -t '=work:gone' '#{pane_current_command}'" "pi
pi"
expect "restore reports them" "grep -c '^resumed 2 of 2 agents' $DIR/restore.out" 1
expect "the interrupted turn is reported" "grep -c '^interrupted work:agent' $DIR/restore.out" 1
typed_line() { r capture-pane -p -t "$1" | grep -v '^ *$' | tail -1; }
expect "a command is retyped with its quoting" "typed_line '=work:cmd' | grep -o \"sh -c 'sleep 100000; :' 'two words'\$\"" "sh -c 'sleep 100000; :' 'two words'"
expect "the zsh hook's text is retyped" "typed_line '=work:hooked' | grep -o 'slp 200000 | cat\$'" "slp 200000 | cat"
expect "a notification summarizes" "cat $DIR/notes2" "Restored 2 tmux sessions|resumed 2 of 2 agents · 1 was mid-turn · 2 commands typed, not run"
expect "the offer is gone" "ls $STATE2/offer.json 2>/dev/null; r show -gv @dingger_restore 2>/dev/null; mbr --print-panel | grep -c '↺'" "0"
expect "neither runs: the shell waits for Enter" \
"r display -p -t '=work:cmd' '#{pane_current_command}'; r display -p -t '=work:hooked' '#{pane_current_command}'" "bash
bash"
expect "windows, panes, layouts, directories, focus, zoom are back" "fingerprint" "$BEFORE"
expect "the terminal's client moved to the restored 0, still attached" "r list-clients -F '#{session_name}'" 0
expect "a used session is left alone" "r list-windows -t =keep -F '#{window_name}'" mine
expect "the candidate is marked restored" \
"python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"status\"])' $STATE2/restore.json" restored
r resize-window -t '=viewer:' -x 100 -y 30
expect "restored windows follow the terminal's size again" "r display -p -t '=0:edit' '#{window_width}'" 100
r resize-window -t '=viewer:' -x 120 -y 40
expect "and back" "r display -p -t '=0:edit' '#{window_width}'" 120
renv "$DINGGER" restore >"$DIR/restore2.out" 2>&1
expect "restoring again changes nothing" "fingerprint" "$BEFORE"
expect "restoring again plans nothing" "grep -cvE '^keep ' $DIR/restore2.out" 0
[ "$FAILS" -gt 0 ] && { echo "--- restore output:"; cat "$DIR/restore.out"; echo "--- before:"; echo "$BEFORE"; echo "--- after:"; fingerprint; }
# Again, now with no tmux running at all (after a reboot, before a terminal
# opens): restoring starts the server. Then "Not now" stays dismissed.
names() { r list-panes -a -F '#{session_name}|#{window_index}|#{?automatic-rename,(auto),#{window_name}}|#{pane_index}|#{pane_current_path}' 2>/dev/null | grep "^$1|"; }
WORK_BEFORE=$(names work)
r kill-server
expect "a new offer: all four sessions, tmux is not running" \
"python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" 4 80
mbr --restore work
expect "restoring with no server starts it and rebuilds the session" "names work" "$WORK_BEFORE"
expect "with the test's shell, not the user's login shell" "r display -p -t '=work:agent' '#{pane_current_command}'" bash
expect "no real agent runs in a test pane (no server)" "real_agents" ""
expect "the offer shrinks to what is still missing" \
"python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" \
"['0', 'keep', 'viewer']" 80
mbr --dismiss-offer >/dev/null
expect "Not now: the offer is gone" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0
expect "and the status line is told" "r show -gv @dingger_restore 2>/dev/null" ""
start_viewer
sleep 2
expect "it stays dismissed when a terminal starts tmux" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0
expect "the layout can still be restored by hand" "renv \"$DINGGER\" restore --dry-run | grep -c '^create keep'" 1
# --- 17. a daemon killed mid-write leaves consistent state -------------------
echo "17. kill during a write"
expect "20 kills right after done: hook and display state agree" \
"\"$ROOT/tests/kill-race.sh\" \"$DINGGER\" 20 | grep -o '^[^:]*: [0-9]* of' | sed 's/.*: //'" "0 of" 1
# --- 18. claude: install its hook; restore resumes its session ---------------
echo "18. claude"
CC="$DIR/cc-config"; mkdir -p "$CC/real"
cat >"$CC/real/settings.json" <<'JSON'
{
"env": {
"TRACE_TO_LANGSMITH": "true"
},
"model": "sonnet",
"hooks": {
"SessionStart": [
{
"matcher": "^(startup|resume|clear|compact|fork)$",
"hooks": [
{
"type": "command",
"command": "bash '/x/herdr-agent-state.sh' session",
"timeout": 10
}
]
}
]
},
"theme": "auto"
}
JSON
cp "$CC/real/settings.json" "$CC/original.json"
ln -s "$CC/real/settings.json" "$CC/settings.json"
ccinstall() { CLAUDE_CONFIG_DIR="$CC" "$DINGGER" install claude; }
ccinstall >"$DIR/ccinstall.out" 2>&1
expect "install claude succeeds" "grep -c '^installed in' $DIR/ccinstall.out" 1
expect "settings: the same keys in the same order, herdr's hook first, dingger's added" \
"python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(list(d), [h[\"command\"].rsplit(\"/\", 1)[-1] for g in d[\"hooks\"][\"SessionStart\"] for h in g[\"hooks\"]])' $CC/settings.json" \
"['env', 'model', 'hooks', 'theme'] [\"herdr-agent-state.sh' session\", 'dingger hook claude']"
expect "everything else unchanged" \
"python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); d[\"hooks\"][\"SessionStart\"].pop(); print(d == json.load(open(sys.argv[2])))' $CC/settings.json $CC/original.json" True
expect "written through the symlink" "[ -L $CC/settings.json ] && cmp -s $CC/settings.json $CC/real/settings.json && echo yes" yes
expect "the original is backed up" "cmp -s $CC/real/settings.json.bak-dingger $CC/original.json && echo same" same
ccinstall >"$DIR/ccinstall2.out" 2>&1
expect "installing again changes nothing" "cat $DIR/ccinstall2.out" "already installed in $(cd "$CC/real" && pwd -P)/settings.json"
echo '{"model": ' >"$CC/broken.json"; cp "$CC/broken.json" "$CC/broken-copy.json"
expect "invalid JSON is not touched" \
"CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; mkdir -p $CC/b; cp $CC/broken.json $CC/b/settings.json; CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; echo \$?; cmp -s $CC/b/settings.json $CC/broken-copy.json && echo unchanged" "1
unchanged"
# The exact command Claude would run, from the installed settings.
HOOK_CMD=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["hooks"]["SessionStart"][-1]["hooks"][0]["command"])' "$CC/settings.json")
expect "the hook prints nothing and exits 0, even with no daemon or bad input" \
"printf '{\"hook_event_name\":\"SessionStart\",\"transcript_path\":\"/x.jsonl\"}' | TMUX_PANE=%99 DINGGER_AGENT_SOCKET=$DIR/none.sock sh -c \"$HOOK_CMD\"; echo garbage | sh -c \"$HOOK_CMD\"; echo \"exit \$?\"" "exit 0"
# A fake claude named claude: records its arguments, runs the SessionStart
# hook with Claude's input (`claude <id>` starts session <id>, `claude
# --resume <id>` resumes it), then waits.
cp /bin/sleep "$DIR/rbin/real/claude" && codesign --force --sign - "$DIR/rbin/real/claude" 2>/dev/null
cat >"$DIR/rbin/claude" <<EOF
#!/bin/sh
echo "\$*" >> $DIR/claude-args
id=\${2:-\$1}
printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","hook_event_name":"SessionStart","source":"startup"}' \
"\$id" "$W/claude/\$id.jsonl" "\$PWD" | DINGGER_AGENT_SOCKET=$DIR/hook2.sock sh -c "$HOOK_CMD"
exec $DIR/rbin/real/claude 100000
EOF
chmod +x "$DIR/rbin/claude"
# "talked" has a transcript; "fresh" never got a message, so it has none.
mkdir -p "$W/claude" && touch "$W/claude/talked.jsonl"
r new-session -d -s cc -n talked -c "$W/two"
r new-window -d -t '=cc:' -n fresh -c "$W/three"
r send-keys -t '=cc:talked' 'claude talked' Enter
r send-keys -t '=cc:fresh' 'claude fresh' Enter
r set-option -p -t '=cc:fresh' @dingger_cmd 'claude fresh'
TALKED=$(r display -p -t '=cc:talked' '#{pane_id}')
expect "claude is detected" "r show -pv -t '=cc:talked' @agent 2>/dev/null; r show -pv -t '=cc:fresh' @agent 2>/dev/null" "claude
claude" 80
expect "its hook's session is kept on its pane" "r show -pv -t $TALKED @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80
expect "snapshot: claude's session" "in_snapshot '\"session\": \"$W/claude/talked.jsonl\"'" True 80
r kill-server
expect "a new restore candidate" \
"python3 -c 'import json,sys; print(\"cc\" in [s[\"name\"] for s in json.load(open(sys.argv[1]))[\"snapshot\"][\"sessions\"]])' $STATE2/restore.json 2>/dev/null" True 80
: >"$DIR/claude-args"
renv "$DINGGER" restore --session cc >"$DIR/restore-cc.out" 2>&1
expect "no real agent runs in a test pane (claude)" "real_agents" ""
expect "claude resumed by id" "cat $DIR/claude-args" "--resume talked"
expect "in its directory" "r display -p -t '=cc:talked' '#{pane_current_path}'" "$(cd "$W/two" && pwd -P)"
expect "restore reports it" "grep -c '^resumed 1 of 1 agents' $DIR/restore-cc.out" 1
expect "no transcript: its command line is typed, not run" \
"typed_line '=cc:fresh' | grep -o 'claude fresh\$'; r display -p -t '=cc:fresh' '#{pane_current_command}'" "claude fresh
bash"
expect "the resumed claude reports its session again" \
"r show -pv -t '=cc:talked' @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80
[ "$FAILS" -gt 0 ] && { echo "--- restore-cc:"; cat "$DIR/restore-cc.out"; }
# Every daemon this run started must stop with it.
kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null
expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0
echo
echo "$PASSES passed, $FAILS failed"
[ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; }
|