diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-05 11:27:00 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-05 11:27:00 +0300 |
| commit | a58874ca96a87fbf6a86e8df7821f29749985b58 (patch) | |
| tree | 655b3f52bf768a2757a07dd52944dce1e4518453 /tmuxrdr | |
| parent | 48cd6744f077a3d59e61bbdcdb1e0bff8da7a540 (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-x | tmuxrdr/scripts/install | 84 | ||||
| -rwxr-xr-x | tmuxrdr/scripts/sync-upstream | 72 | ||||
| -rwxr-xr-x | tmuxrdr/scripts/test-e2e | 249 |
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 ]] |
