aboutsummaryrefslogtreecommitdiff
path: root/tmuxrdr
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-05 11:27:00 +0300
committerIgor Soarez <igor@soarez.org>2026-10-05 11:27:00 +0300
commita58874ca96a87fbf6a86e8df7821f29749985b58 (patch)
tree655b3f52bf768a2757a07dd52944dce1e4518453 /tmuxrdr
parent48cd6744f077a3d59e61bbdcdb1e0bff8da7a540 (diff)
tmuxrdr: install, end-to-end test and upstream sync scripts
install links ~/.config/herdr/config.toml and the plugin to this checkout and registers the plugin. test-e2e presses each binding in a herdr client running in a throwaway tmux and session, and checks the result through the herdr CLI. sync-upstream rebases the tmuxrdr commits onto a newer herdr release, keeping the old branch as a backup.
Diffstat (limited to 'tmuxrdr')
-rwxr-xr-xtmuxrdr/scripts/install84
-rwxr-xr-xtmuxrdr/scripts/sync-upstream72
-rwxr-xr-xtmuxrdr/scripts/test-e2e249
3 files changed, 405 insertions, 0 deletions
diff --git a/tmuxrdr/scripts/install b/tmuxrdr/scripts/install
new file mode 100755
index 00000000..f4c0f1ea
--- /dev/null
+++ b/tmuxrdr/scripts/install
@@ -0,0 +1,84 @@
+#!/bin/bash
+
+# Put this machine on tmuxrdr's config and plugin. Dry run by default; pass
+# `yes` to apply -- the same interface as dot's link.sh.
+#
+# ~/.local/share/tmuxrdr/plugin -> <repo>/tmuxrdr/plugin
+# the stable path config.toml's popups run from, whatever the checkout
+# is called; registered with `herdr plugin link`
+# ~/.config/herdr/config.toml -> <repo>/tmuxrdr/config/config.toml
+# herdr saves its settings with an in-place write (src/config/write.rs),
+# so the link survives and every change lands here, under git. A real
+# file already there is kept beside it as config.toml.pre-tmuxrdr.
+#
+# Run it again after moving the checkout. Uses whichever herdr is first on
+# PATH, or $HERDR.
+
+set -euo pipefail
+
+repo="$(cd "$(dirname "$0")/../.." && pwd -P)"
+herdr="${HERDR:-herdr}"
+apply="${1:-}"
+plugin_id="tmuxrdr.tmux-mode"
+plugin_link="${XDG_DATA_HOME:-$HOME/.local/share}/tmuxrdr/plugin"
+config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/herdr"
+config_link="$config_dir/config.toml"
+
+run() {
+ if [[ "$apply" == "yes" ]]; then
+ "$@"
+ else
+ echo "would run: $*"
+ fi
+}
+
+# link TARGET LINK: make LINK a symlink to TARGET, keeping a real file that is
+# in the way as LINK.pre-tmuxrdr. A real directory is left alone: too much to
+# guess about.
+link() {
+ local target="$1" link="$2"
+ if [[ -L "$link" && "$(readlink "$link")" == "$target" ]]; then
+ echo "ok: $link"
+ return
+ fi
+ if [[ -d "$link" && ! -L "$link" ]]; then
+ >&2 echo "ERROR: $link is a directory; move it away and run again"
+ exit 1
+ fi
+ if [[ -f "$link" && ! -L "$link" ]]; then
+ run mv "$link" "$link.pre-tmuxrdr"
+ fi
+ run mkdir -p "$(dirname "$link")"
+ run ln -sfn "$target" "$link"
+ [[ "$apply" == "yes" ]] && echo "linked: $link -> $target"
+ return 0
+}
+
+command -v "$herdr" >/dev/null || { >&2 echo "ERROR: no $herdr on PATH"; exit 1; }
+command -v jq >/dev/null || { >&2 echo "ERROR: the plugin needs jq"; exit 1; }
+
+link "$repo/tmuxrdr/plugin" "$plugin_link"
+link "$repo/tmuxrdr/config/config.toml" "$config_link"
+
+# herdr records the link resolved, so compare against the checkout itself;
+# registered under another path -- an older checkout -- means link again.
+registered="$("$herdr" plugin list --json 2>/dev/null \
+ | jq -r --arg id "$plugin_id" '.. | objects | select(.plugin_id? == $id) | .plugin_root' \
+ | head -n 1)"
+if [[ -n "$registered" && "$registered" != "$repo/tmuxrdr/plugin" ]]; then
+ run "$herdr" plugin unlink "$plugin_id"
+ registered=""
+fi
+if [[ -z "$registered" ]]; then
+ run "$herdr" plugin link "$plugin_link" >/dev/null
+ [[ "$apply" == "yes" ]] && echo "registered: plugin $plugin_id"
+else
+ echo "ok: plugin $plugin_id"
+fi
+
+if [[ "$apply" == "yes" ]]; then
+ "$herdr" config check
+ echo "Running herdr picks this up on prefix+r."
+else
+ echo "Execute with: $0 yes"
+fi
diff --git a/tmuxrdr/scripts/sync-upstream b/tmuxrdr/scripts/sync-upstream
new file mode 100755
index 00000000..44c1b010
--- /dev/null
+++ b/tmuxrdr/scripts/sync-upstream
@@ -0,0 +1,72 @@
+#!/bin/bash
+
+# Move tmuxrdr onto a newer herdr release: rebase the tmuxrdr commits from the
+# release they sit on onto the new one.
+#
+# tmuxrdr/scripts/sync-upstream newest stable release (vX.Y.Z)
+# tmuxrdr/scripts/sync-upstream v0.9.4 that release
+#
+# Releases, not upstream's master: a release is what Homebrew ships, so a
+# tmuxrdr build speaks the same protocol as any stock herdr server around it.
+# The base is the newest release tag behind HEAD; the tmuxrdr commits are
+# everything after it. The branch as it was is kept as tmuxrdr-on-<old tag>,
+# so a bad rebase costs nothing: `git switch -C tmuxrdr tmuxrdr-on-<old tag>`.
+# rerere is on in this checkout, so a conflict resolved once is resolved again
+# by itself on the next release.
+
+set -euo pipefail
+
+cd "$(dirname "$0")/../.."
+branch="tmuxrdr"
+target="${1:-}"
+
+die() { >&2 echo "ERROR: $*"; exit 1; }
+
+[[ "$(git symbolic-ref --short -q HEAD)" == "$branch" ]] || die "check out $branch first"
+[[ -z "$(git status --porcelain)" ]] || die "uncommitted changes; commit or stash them first"
+
+git fetch --quiet --tags --prune upstream
+
+old="$(git describe --tags --abbrev=0 --match 'v[0-9]*' HEAD)"
+if [[ -z "$target" ]]; then
+ target="$(git tag -l 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)"
+fi
+git rev-parse -q --verify "refs/tags/$target^{commit}" >/dev/null || die "no release tag $target"
+
+if [[ "$target" == "$old" ]]; then
+ echo "up to date: $branch is on $old"
+ exit 0
+fi
+[[ "$(printf '%s\n' "$old" "$target" | sort -V | tail -n 1)" == "$target" ]] \
+ || die "$target is older than $old, the release $branch is on"
+
+echo "$branch: $(git rev-list --count "$old..HEAD") commits on $old"
+git log --oneline --reverse "$old..HEAD" | sed 's/^/ /'
+echo "rebasing onto $target ($(git rev-list --count "$old..$target") upstream commits)"
+
+backup="$branch-on-$old"
+git branch -f "$backup" HEAD
+
+if ! git rebase --onto "$target" "$old" "$branch"; then
+ cat <<EOF
+
+The rebase stopped on a conflict -- in a code patch, since everything else
+lives in tmuxrdr/, which upstream never touches.
+ git status what conflicts
+ ... resolve, git add, then: git rebase --continue
+ git rebase --abort give up: $branch goes back as it was
+The branch before the rebase is also kept as $backup.
+EOF
+ exit 1
+fi
+
+echo
+echo "$branch is on $target; the old one is kept as $backup."
+installed="$("${HERDR:-herdr}" --version 2>/dev/null | awk '{print "v" $2}')"
+if [[ "$installed" != "$target" ]]; then
+ echo "herdr on PATH is ${installed:-missing}, not $target: test against $target"
+ echo "(upgrade it, or build this checkout -- README.md) before trusting test-e2e."
+fi
+HERDR_CONFIG_PATH="$PWD/tmuxrdr/config/config.toml" "${HERDR:-herdr}" config check \
+ || echo "config check found issues against the herdr on PATH; see above"
+echo "Next: tmuxrdr/scripts/test-e2e"
diff --git a/tmuxrdr/scripts/test-e2e b/tmuxrdr/scripts/test-e2e
new file mode 100755
index 00000000..5913afa8
--- /dev/null
+++ b/tmuxrdr/scripts/test-e2e
@@ -0,0 +1,249 @@
+#!/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/herdr test another binary, e.g. a tmuxrdr build
+# 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:-herdr}"
+delay="${TMUXRDR_E2E_DELAY:-0.8}"
+session="tmuxrdr-e2e-$$"
+T=(tmux -L "tmuxrdr-e2e-$$" -f /dev/null)
+H=("$herdr" --session "$session")
+plugin_id="tmuxrdr.tmux-mode"
+work="$(mktemp -d)"
+cfg="$work/config.toml"
+state="${XDG_STATE_HOME:-$HOME/.local/state}/herdr/plugins/$plugin_id/$session"
+
+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
+"$herdr" 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'; }
+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"
+
+# --- 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
+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
+
+# --- 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' '│ *1 +'
+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 | sed -n 1p | grep -c -E '│ *1 +' | 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 ]]