#!/bin/bash # tmuxrdr end-to-end test: the tmux bindings pressed in a real herdr client, # each effect checked through the herdr CLI. Run after every rebase onto a new # herdr (scripts/sync-upstream) and after changing the config or plugin. # # The client runs in a throwaway tmux server -- its own socket, no tmux.conf, # tmux's prefix switched off so ctrl+b reaches herdr -- against a throwaway # herdr session and a copy of tmuxrdr/config/config.toml. Your sessions, your # tmux and the config file itself are left alone; everything is removed at the # end. The plugin is global to herdr, so it must be installed first # (scripts/install yes). The prefix+] check pastes the clipboard into cat in # the throwaway pane -- printed, never run -- and is skipped when the # clipboard holds no text. # # HERDR=path/to/binary test another tmuxrdr build than the installed # tmuxrdr, e.g. target/release/herdr # TMUXRDR_E2E_DELAY=1.2 seconds after each key (default 0.8); raise it on # a loaded machine before suspecting a failure # # Exits non-zero when any check fails. set -uo pipefail repo="$(cd "$(dirname "$0")/../.." && pwd -P)" herdr="${HERDR:-tmuxrdr}" delay="${TMUXRDR_E2E_DELAY:-0.8}" session="tmuxrdr-e2e-$$" T=(tmux -L "tmuxrdr-e2e-$$" -f /dev/null) # The script may run from inside a herdr pane, whose env names that pane; the # CLI would then look for it on the throwaway server and miss. u=(env -u HERDR_PANE_ID -u HERDR_TAB_ID -u HERDR_WORKSPACE_ID \ -u HERDR_SOCKET_PATH -u HERDR_CLIENT_SOCKET_PATH -u HERDR_ENV) H=("${u[@]}" "$herdr" --session "$session") plugin_id="tmuxrdr.tmux-mode" work="$(mktemp -d)" cfg="$work/config.toml" state="" # set once the binary's directory name is known, below pass=0 fail=0 skip=0 cleanup() { "${T[@]}" kill-server 2>/dev/null # tmux leaves its socket behind when the server ends with the session. rm -f "${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)/tmuxrdr-e2e-$$" "$herdr" session stop "$session" >/dev/null 2>&1 "$herdr" session delete "$session" >/dev/null 2>&1 rm -rf "$work" "$state" } trap cleanup EXIT die() { >&2 echo "ERROR: $*"; exit 2; } for tool in tmux jq "$herdr"; do command -v "$tool" >/dev/null || die "$tool not found" done # The binary's own directory name -- tmuxrdr for a tmuxrdr build -- from the # config path it reports; plugin state lives under the same name. app="$(basename "$(dirname "$("$herdr" --help | sed -n 's/^Config: *//p')")")" [[ "$app" == tmuxrdr ]] || die "$herdr keeps its files under '$app', not tmuxrdr: not a tmuxrdr build" state="${XDG_STATE_HOME:-$HOME/.local/state}/$app/plugins/$plugin_id/$session" "${H[@]}" plugin list --json 2>/dev/null \ | jq -e --arg id "$plugin_id" --arg root "$repo/tmuxrdr/plugin" \ '[.. | objects | select(.plugin_id? == $id and .plugin_root? == $root)] | length > 0' >/dev/null \ || die "$plugin_id is not registered from this checkout; run tmuxrdr/scripts/install yes" [[ -e "${XDG_DATA_HOME:-$HOME/.local/share}/tmuxrdr/plugin/bin" ]] \ || die "no ~/.local/share/tmuxrdr/plugin; run tmuxrdr/scripts/install yes" cp "$repo/tmuxrdr/config/config.toml" "$cfg" # --- helpers ----------------------------------------------------------------- k() { "${T[@]}" send-keys -t e2e "$@"; sleep "$delay"; } type_line() { "${T[@]}" send-keys -t e2e -l "$1"; "${T[@]}" send-keys -t e2e Enter; sleep "$delay"; } check() { if [[ -n "$2" && "$2" == "$3" ]]; then pass=$((pass + 1)); echo "PASS $1" else fail=$((fail + 1)); echo "FAIL $1 -- got '${2}', want '${3}'" fi } # check_seen NAME REGEX: something matching REGEX shows on screen within ~3 s. check_seen() { local i for i in $(seq 12); do if "${T[@]}" capture-pane -p -t e2e | grep -q -E -- "$2"; then pass=$((pass + 1)); echo "PASS $1"; return fi sleep 0.25 done fail=$((fail + 1)); echo "FAIL $1 -- nothing on screen matching /$2/" } skip() { skip=$((skip + 1)); echo "SKIP $1 -- $2"; } focus() { "${H[@]}" pane current | jq -r '.result.pane.pane_id'; } order() { "${H[@]}" pane layout --pane "$(focus)" | jq -r '[.result.layout.panes[].pane_id] | join(" ")'; } width() { "${H[@]}" pane layout --pane "$1" | jq -r --arg p "$1" '.result.layout.panes[] | select(.pane_id == $p) | .rect.width'; } ws() { "${H[@]}" workspace list | jq -r '.result.workspaces[] | select(.focused) | .workspace_id'; } ws_label() { "${H[@]}" workspace list | jq -r '.result.workspaces[] | select(.focused) | .label'; } tab() { "${H[@]}" tab list --workspace "$(ws)" | jq -r '.result.tabs[] | select(.focused) | .tab_id'; } tab_pos() { "${H[@]}" tab list --workspace "$(ws)" | jq -r '[.result.tabs[] | .focused] | index(true) + 1'; } ws_count() { "${H[@]}" workspace list | jq -r '.result.workspaces | length'; } prompt() { "${T[@]}" send-keys -t e2e C-b :; sleep "$delay"; type_line "$1"; } tab_count() { "${H[@]}" tab list --workspace "$(ws)" | jq -r '.result.tabs | length'; } tab_panes() { "${H[@]}" pane list --workspace "$(ws)" | jq -r --arg t "${1:-$(tab)}" '[.result.panes[] | select(.tab_id == $t)] | length'; } # --- start ------------------------------------------------------------------- "${T[@]}" new-session -d -s e2e -x 160 -y 45 -c /tmp \ "env -u HERDR_ENV HERDR_CONFIG_PATH='$cfg' '$herdr' --session '$session'" \; \ set -g prefix None \; set -g prefix2 None \; set -g status off for _ in $(seq 40); do [[ "$("${H[@]}" workspace list 2>/dev/null | jq -r '.result.workspaces | length' 2>/dev/null)" == 1 ]] && break sleep 0.25 done [[ "$(focus 2>/dev/null)" == w1:p1 ]] || die "herdr client did not come up" echo "herdr $("$herdr" --version | awk '{print $2}'), session $session, config copy of tmuxrdr/config/config.toml" diagnostics="$(HERDR_CONFIG_PATH="$cfg" "$herdr" config check 2>&1)" check 'config check: no unknown keys or bad bindings' "$(head -n 1 <<<"$diagnostics")" "config: ok" [[ "$diagnostics" == "config: ok" ]] || sed 's/^/ /' <<<"$diagnostics" # --- panes ------------------------------------------------------------------- k C-b %; check 'prefix+% split side by side' "$(tab_panes)" 2 k C-b '"'; check 'prefix+" split stacked' "$(tab_panes)" 3 k C-b h; check 'prefix+h select-pane -L' "$(focus)" w1:p1 k C-b l; check 'prefix+l select-pane -R' "$(focus)" w1:p2 k C-b j; check 'prefix+j select-pane -D' "$(focus)" w1:p3 k C-b k; check 'prefix+k select-pane -U' "$(focus)" w1:p2 k C-b Down; check 'prefix+Down' "$(focus)" w1:p3 k C-b Up; check 'prefix+Up' "$(focus)" w1:p2 k C-b Left; check 'prefix+Left' "$(focus)" w1:p1 k C-b Right; check 'prefix+Right' "$(focus)" w1:p2 k C-b o; check 'prefix+o next pane' "$(focus)" w1:p3 k C-b '\;'; check 'prefix+; last pane' "$(focus)" w1:p2 k C-b '*'; check 'prefix+* new-pane' "$(tab_panes)" 4 k C-b z; check 'prefix+z zoom' "$("${H[@]}" pane layout --pane "$(focus)" | jq -r '.result.layout.zoomed')" true k C-b z # Expected orders are worked out from the order the splits left, a b c d in # pane-index order with the first one focused -- tmux 3.7c's behaviour, as # measured: swap-pane moves the current pane and keeps it current; # rotate-window moves every pane and keeps focus on the position. k C-b Left read -r a b c d <<<"$(order)" check 'first pane focused for the pane-order checks' "$(focus)" "$a" k C-b '}'; check 'prefix+} swap-pane -D' "$(order) / $(focus)" "$b $a $c $d / $a" k C-b '{'; check 'prefix+{ swap-pane -U' "$(order) / $(focus)" "$a $b $c $d / $a" k C-b C-o; check 'prefix+C-o rotate-window, focus keeps the position' "$(order) / $(focus)" "$b $c $d $a / $b" k C-b M-o; check 'prefix+M-o rotate-window -D' "$(order) / $(focus)" "$a $b $c $d / $a" k C-b m; check 'prefix+m mark' "$(cat "$state/marked" 2>/dev/null)" "$a" k C-b M; check 'prefix+M clear mark' "$([[ -e "$state/marked" ]] && echo marked || echo cleared)" cleared w0="$(width "$a")" k C-b C-Right; w1="$(width "$a")" check 'prefix+C-Right resize by about a cell' "$(( w1 - w0 >= 1 && w1 - w0 <= 2 ))" 1 k C-b M-Right; w2="$(width "$a")" check 'prefix+M-Right resize by about 5 cells' "$(( w2 - w1 >= 4 && w2 - w1 <= 9 ))" 1 k C-b E check 'prefix+E select-layout -E evens the columns' "$(( $(width "$a") - $(width "$b") <= 1 && $(width "$b") - $(width "$a") <= 1 ))" 1 # --- messages ---------------------------------------------------------------- "${T[@]}" send-keys -t e2e C-b i check_seen 'prefix+i display-message, tmux format' '\[tmp\] 1:[^,]*, current pane 0 - \([0-9]{2}:[0-9]{2} [0-9]{2}-[A-Z][a-z]{2}-[0-9]{2}\)' sleep 3 "${T[@]}" send-keys -t e2e C-b 0 check_seen "prefix+0 nothing at index 0" "can't find window: 0" sleep 3 # --- windows ----------------------------------------------------------------- k C-b c; k C-b c; check 'prefix+c new-window' "$(tab_count)" 3 k C-b p; check 'prefix+p previous-window' "$(tab_pos)" 2 k C-b n; check 'prefix+n next-window' "$(tab_pos)" 3 k C-b 1; check 'prefix+1 select-window' "$(tab_pos)" 1 k C-b 3; check 'prefix+3 select-window' "$(tab_pos)" 3 # ui.tab_style = "tmux": tmux's status line, the window before last flagged "-" # and unnamed tabs called after their directory (/private/tmp). check_seen 'tab row in tmux format: [session] #I:#W#F' '\[tmp\] 1:tmp- 2:tmp 3:tmp\*' k C-b 1 k C-b '!' check 'prefix+! break-pane' "$(tab_count) $(tab_panes) $(tab_panes w1:t1)" "4 1 3" "${T[@]}" send-keys -t e2e C-b "'"; check_seen "prefix+' index prompt" '\(index\)' type_line 1; check "prefix+' select-window 1" "$(tab_pos)" 1 "${T[@]}" send-keys -t e2e C-b .; check_seen 'prefix+. target prompt' '\(target\)' type_line 2; check 'prefix+. move-window 2' "$(tab_pos)" 2 "${T[@]}" send-keys -t e2e C-b x; check_seen 'prefix+x asks' 'kill-pane [0-9]+\? \(y/n\)' n0="$(tab_panes)" k n; check 'prefix+x n keeps the pane' "$(tab_panes)" "$n0" k C-b x; k y; check 'prefix+x y kills it' "$(tab_panes)" "$(( n0 - 1 ))" "${T[@]}" send-keys -t e2e C-b '&'; check_seen 'prefix+& asks' 'kill-window [^ ]+\? \(y/n\)' k n; check 'prefix+& n keeps the window' "$(tab_count)" 4 # --- sessions ---------------------------------------------------------------- "${H[@]}" workspace create --cwd /tmp --label other >/dev/null; sleep 0.5 k C-b ')'; check 'prefix+) next session' "$(ws_label)" other k C-b '('; check 'prefix+( previous session' "$(ws_label)" tmp k C-b L; check 'prefix+L last session' "$(ws_label)" other k C-b L; check 'prefix+L toggles back' "$(ws_label)" tmp # tmux's session commands, on workspaces, from the : prompt. prompt 'new-session -s e2e-new'; check ':new-session -s creates and switches' "$(ws_label) $(ws_count)" "e2e-new 3" prompt 'switch-client -t tmp'; check ':switch-client -t' "$(ws_label)" tmp prompt 'new -A -s e2e-new'; check ':new -A switches to the existing one' "$(ws_label) $(ws_count)" "e2e-new 3" prompt 'switch-client -p'; check ':switch-client -p' "$(ws_label)" other prompt 'new-session -s tmp'; check_seen ':new-session on a taken name' 'duplicate session: tmp'; k x prompt 'ls'; check_seen ':ls lists the sessions' 'e2e-new: [0-9]+ windows'; k x prompt 'kill-session -t e2e-new'; check ':kill-session -t' "$(ws_count)" 2 prompt 'switch-client -t tmp'; check ':switch-client -t back' "$(ws_label)" tmp # --- prompts, choosers, modes ------------------------------------------------ "${T[@]}" send-keys -t e2e C-b :; check_seen 'prefix+: command prompt' '│:' type_line 'rename-window e2e'; check ':rename-window' "$("${H[@]}" tab get "$(tab)" | jq -r '.result.tab.label')" e2e "${T[@]}" send-keys -t e2e C-b :; sleep "$delay"; type_line 'display-message tmuxrdr-e2e-message' check_seen ':display-message' 'tmuxrdr-e2e-message' for key in s w f q; do "${T[@]}" send-keys -t e2e C-b "$key" case "$key" in s) check_seen 'prefix+s choose a session' 'NAVIGATE' ;; *) check_seen "prefix+$key choose a window" 'search agents and terminals' ;; esac k Escape done "${T[@]}" send-keys -t e2e C-b '?'; check_seen 'prefix+? list keys' 'keybinds'; k Escape "${T[@]}" send-keys -t e2e C-b /; check_seen 'prefix+/ describe keys' 'keybinds'; k Escape "${T[@]}" send-keys -t e2e C-b '['; check_seen 'prefix+[ copy mode' 'COPY'; k q "${T[@]}" send-keys -t e2e C-b t; check_seen 'prefix+t clock' '██'; k q "${T[@]}" send-keys -t e2e C-b '~'; check_seen 'prefix+~ messages' 'INFO|WARN|DEBUG|ERROR'; k q "${T[@]}" send-keys -t e2e C-b '$'; check_seen 'prefix+$ rename-session' 'rename workspace'; k Escape "${T[@]}" send-keys -t e2e C-b ,; check_seen 'prefix+, rename-window' 'rename tab'; k Escape "${T[@]}" send-keys -t e2e C-b C; check_seen 'prefix+C customize' 'settings'; k Escape # --- input ------------------------------------------------------------------- p="$(focus)" "${H[@]}" pane run "$p" 'cat -v' >/dev/null; sleep "$delay" k C-b C-b; k Enter check 'prefix twice sends a literal C-b (send-prefix)' \ "$("${H[@]}" pane read "$p" --source visible | grep -q '\^B' && echo yes)" yes k C-c # Pasted into cat, which prints it and runs nothing, whatever the clipboard # holds; the check looks for its last line, the one sure to be on screen. line="$( (pbpaste 2>/dev/null || true) | grep -v '^[[:space:]]*$' | tail -n 1 | cut -c1-40)" if [[ -n "$line" ]]; then "${H[@]}" pane run "$p" 'cat' >/dev/null; sleep "$delay" k C-b ']'; sleep 0.5 check 'prefix+] paste-buffer' \ "$("${H[@]}" pane read "$p" --source visible | grep -q -F -- "$line" && echo yes)" yes k C-c else skip 'prefix+] paste-buffer' 'no text on the clipboard' fi # --- reload, then detach ----------------------------------------------------- k C-b ')' # "other": a workspace with a single tab check_seen 'tab row before reload' '\[other\] 1:tmp\*' sed -i.bak 's/^hide_tab_bar_when_single_tab = false/hide_tab_bar_when_single_tab = true/' "$cfg" k C-b r check 'prefix+r reload hides the single tab row' \ "$("${T[@]}" capture-pane -p -t e2e | grep -c -E '\[other\] 1:tmp\*' | tr -d ' ')" 0 k C-b d; sleep 1 check 'prefix+d detach ends the client' "$("${T[@]}" has-session -t e2e 2>/dev/null && echo attached || echo detached)" detached check 'the server keeps running' "$("${H[@]}" status server 2>/dev/null | awk 'NR == 1 {print $2}')" running echo echo "$pass passed, $fail failed, $skip skipped" [[ "$fail" -eq 0 ]]