Files
ssh-netbox-wrapper/internal/multiplex/multiplex_test.go
T
Sebastian UnterschützandClaude Opus 5.5 396d489187 feat(multi): tmux keys to toggle broadcast and exclude single panes
In windows opened by netssh multi, prefix S toggles broadcast for all
panes and prefix e excludes the current pane (a pane-level
synchronize-panes off, shown as [SOLO] in the border) or includes it
again. netssh sync --pane does the same from a shell.

S and e are unbound in a default tmux (E would be "spread evenly").
netssh writes the bindings to a small tmux config and sources it, binds
a key only if the user's tmux.conf does not use it (netssh's own earlier
binding is refreshed), and the bindings act only in windows marked
@netssh. multi.sync_key and multi.solo_key change the keys or turn them
off (none); also in the configurator. zellij syncs whole tabs only, so
excluding a pane is tmux-only.

testenv: the render test presses the keys through the xterm client
(send-keys -K) and checks [SOLO], that broadcast skips the excluded
pane, that S toggles, and that other windows are unaffected. The client
gets GNU grep: BusyBox grep -oF counted only one match per line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 13:32:32 +01:00

282 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package multiplex
import (
"errors"
"fmt"
"strings"
"testing"
)
// fake records commands and answers like tmux and zellij would.
type fake struct {
env map[string]string
installed map[string]bool
sessions map[string]bool
calls []string
exec []string
files map[string]string
syncState string
keys map[string]string // existing prefix bindings by key
solo string
n int
}
func newFake(env map[string]string, installed ...string) *fake {
f := &fake{env: env, installed: map[string]bool{}, sessions: map[string]bool{}, files: map[string]string{}, syncState: "off"}
for _, name := range installed {
f.installed[name] = true
}
return f
}
func (f *fake) Output(argv ...string) (string, error) {
f.calls = append(f.calls, strings.Join(argv, " "))
f.n++
switch argv[1] {
case "new-window", "new-session":
return fmt.Sprintf("@%d %%%d", f.n, f.n), nil
case "split-window":
return fmt.Sprintf("%%%d", f.n), nil
case "has-session":
if f.sessions[strings.TrimPrefix(argv[3], "=")] {
return "", nil
}
return "", errors.New("can't find session")
case "show-window-options":
return f.syncState, nil
case "list-keys":
return f.keys[argv[4]], nil
case "display-message":
return f.solo, nil
}
return "", nil
}
func (f *fake) Exec(argv ...string) error { f.exec = argv; return nil }
func (f *fake) LookPath(name string) (string, error) {
if f.installed[name] {
return "/usr/bin/" + name, nil
}
return "", errors.New("not found")
}
func (f *fake) Getenv(key string) string { return f.env[key] }
func (f *fake) WriteFile(path string, data []byte) error {
f.files[path] = string(data)
return nil
}
func panes(names ...string) []Pane {
out := make([]Pane, 0, len(names))
for _, name := range names {
out = append(out, Pane{Title: name, Argv: []string{"/usr/bin/netssh", name}})
}
return out
}
func TestDetect(t *testing.T) {
for _, tc := range []struct {
requested string
env map[string]string
installed []string
want string
}{
{"", map[string]string{"TMUX": "/tmp/tmux"}, []string{"tmux", "zellij"}, Tmux},
{"auto", map[string]string{"ZELLIJ": "0"}, []string{"tmux", "zellij"}, Zellij},
{"", nil, []string{"tmux", "zellij"}, Tmux},
{"", nil, []string{"zellij"}, Zellij},
{"zellij", map[string]string{"TMUX": "x"}, []string{"tmux", "zellij"}, Zellij},
} {
got, err := Detect(tc.requested, newFake(tc.env, tc.installed...))
if err != nil || got != tc.want {
t.Errorf("%+v: got %q %v", tc, got, err)
}
}
if _, err := Detect("", newFake(nil)); !errors.Is(err, ErrNone) {
t.Errorf("nothing installed: %v", err)
}
if _, err := Detect("tmux", newFake(nil, "zellij")); err == nil {
t.Error("missing requested multiplexer accepted")
}
}
func TestTmuxInside(t *testing.T) {
f := newFake(map[string]string{"TMUX": "/tmp/tmux-1/default"}, "tmux")
if err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", Sync: true, SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
script := "sh -c " + paneScript + " netssh-pane /usr/bin/netssh "
want := []string{
"tmux new-window -P -F #{window_id} #{pane_id} -n pve " + script + "a",
"tmux split-window -t @1 -P -F #{pane_id} " + script + "b",
"tmux select-layout -t @1 tiled",
"tmux split-window -t @1 -P -F #{pane_id} " + script + "c",
"tmux select-layout -t @1 tiled",
"tmux set-option -p -t %1 @netssh_host a",
"tmux set-option -p -t %2 @netssh_host b",
"tmux set-option -p -t %4 @netssh_host c",
"tmux set-window-option -t @1 @netssh 1",
"tmux set-window-option -t @1 pane-border-status top",
"tmux set-window-option -t @1 pane-border-format #{@netssh_host}#{?pane_synchronized, [SYNC],#{?@netssh_solo, [SOLO],}} ",
"tmux set-window-option -t @1 synchronize-panes on",
"tmux show-options -gv prefix",
"tmux list-keys -T prefix S",
"tmux list-keys -T prefix e",
"tmux source-file /tmp/l/netssh-multi.tmux.conf",
"tmux select-window -t @1",
}
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
t.Fatalf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
}
if f.exec != nil {
t.Fatal("inside tmux must not attach")
}
}
func TestTmuxOutsideSplitsWindowsAndAvoidsSessionNames(t *testing.T) {
f := newFake(nil, "tmux")
f.sessions["netssh-pve"] = true
var hints []string
err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Hint: func(s string) { hints = append(hints, s) }}, f)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(f.calls, "\n")
for _, want := range []string{
"tmux has-session -t =netssh-pve-2",
"tmux new-session -d -x 240 -y 70 -s netssh-pve-2 -P -F #{window_id} #{pane_id} -n pve-1 ",
"tmux new-window -t netssh-pve-2: -P -F #{window_id} #{pane_id} -n pve-2 ",
"tmux set-option -t netssh-pve-2 status-left-length 40",
} {
if !strings.Contains(joined, want) {
t.Errorf("missing %q in\n%s", want, joined)
}
}
if strings.Contains(joined, "synchronize-panes") {
t.Error("sync set without --sync")
}
if strings.Join(f.exec, " ") != "tmux attach-session -t netssh-pve-2" || len(hints) != 1 {
t.Fatalf("exec %v hints %v", f.exec, hints)
}
}
func TestZellijOutsideWritesGridLayout(t *testing.T) {
f := newFake(nil, "zellij")
if err := Open(Zellij, panes("a", "b", "c", `q"x`), Options{Label: "pve", LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
layout := f.files["/tmp/l/netssh-multi.kdl"]
for _, want := range []string{
`plugin location="zellij:tab-bar"`,
`tab name="pve" focus=true {`,
`pane split_direction="vertical" {`,
`pane name="a" command="/usr/bin/netssh" {`,
`args "a"`,
`pane name="q\"x" command="/usr/bin/netssh" {`,
} {
if !strings.Contains(layout, want) {
t.Errorf("layout lacks %q:\n%s", want, layout)
}
}
// 4 panes → 2 columns × 2 rows.
if n := strings.Count(layout, `split_direction="vertical"`); n != 2 {
t.Errorf("%d rows, want 2:\n%s", n, layout)
}
if strings.Join(f.exec, " ") != "zellij --layout /tmp/l/netssh-multi.kdl" {
t.Fatalf("exec %v", f.exec)
}
}
func TestZellijInsideAddsTabs(t *testing.T) {
f := newFake(map[string]string{"ZELLIJ": "0"}, "zellij")
if err := Open(Zellij, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Sync: true, LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
want := []string{
"zellij action new-tab --layout /tmp/l/netssh-multi-1.kdl",
"zellij action toggle-active-sync-tab",
"zellij action new-tab --layout /tmp/l/netssh-multi-2.kdl",
"zellij action toggle-active-sync-tab",
}
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
t.Fatalf("calls:\n%s", strings.Join(f.calls, "\n"))
}
if !strings.Contains(f.files["/tmp/l/netssh-multi-2.kdl"], `tab name="pve-2"`) {
t.Fatalf("second tab: %s", f.files["/tmp/l/netssh-multi-2.kdl"])
}
}
func TestToggleSync(t *testing.T) {
f := newFake(map[string]string{"TMUX": "x"})
if state, err := ToggleSync(f, false); err != nil || state != "broadcast on" || f.calls[1] != "tmux set-window-option synchronize-panes on" {
t.Fatalf("%q %v %v", state, err, f.calls)
}
f.syncState = "on"
if state, _ := ToggleSync(f, false); state != "broadcast off" {
t.Fatalf("%q", state)
}
z := newFake(map[string]string{"ZELLIJ": "0"})
if _, err := ToggleSync(z, false); err != nil || z.calls[0] != "zellij action toggle-active-sync-tab" {
t.Fatalf("%v %v", err, z.calls)
}
if _, err := ToggleSync(newFake(nil), false); err == nil {
t.Fatal("outside a multiplexer accepted")
}
}
func TestTmuxKeyBindings(t *testing.T) {
f := newFake(map[string]string{"TMUX": "x"}, "tmux")
var hint string
if err := Open(Tmux, panes("a"), Options{SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l", Hint: func(s string) { hint = s }}, f); err != nil {
t.Fatal(err)
}
conf := f.files["/tmp/l/netssh-multi.tmux.conf"]
for _, want := range []string{"bind-key -T prefix S if-shell -F '#{@netssh}'", "bind-key -T prefix e if-shell", "set-option -p synchronize-panes off"} {
if !strings.Contains(conf, want) {
t.Errorf("conf lacks %q:\n%s", want, conf)
}
}
if hint != "keys: C-b S broadcast to all panes, C-b e exclude/include this pane" {
t.Errorf("hint %q", hint)
}
// A key bound in the user's tmux.conf is left alone; netssh's own is replaced.
f = newFake(map[string]string{"TMUX": "x"}, "tmux")
f.keys = map[string]string{"S": "bind-key -T prefix S kill-server", "e": "bind-key -T prefix e if-shell -F #{@netssh} …"}
if err := Open(Tmux, panes("a"), Options{SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l", Hint: func(s string) { hint = s }}, f); err != nil {
t.Fatal(err)
}
conf = f.files["/tmp/l/netssh-multi.tmux.conf"]
if strings.Contains(conf, "prefix S ") || !strings.Contains(conf, "prefix e ") {
t.Errorf("user binding overwritten or own binding not refreshed:\n%s", conf)
}
if !strings.Contains(hint, "C-b S kept your own binding") {
t.Errorf("hint %q", hint)
}
// No keys configured: nothing is bound.
f = newFake(map[string]string{"TMUX": "x"}, "tmux")
if err := Open(Tmux, panes("a"), Options{LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
if len(f.files) != 0 || strings.Contains(strings.Join(f.calls, "\n"), "source-file") {
t.Errorf("bound keys although disabled: %v", f.calls)
}
}
func TestToggleSyncPane(t *testing.T) {
f := newFake(map[string]string{"TMUX": "x"})
if state, err := ToggleSync(f, true); err != nil || state != "pane excluded from broadcast" {
t.Fatalf("%q %v", state, err)
}
if got := strings.Join(f.calls[1:], "; "); got != "tmux set-option -p synchronize-panes off; tmux set-option -p @netssh_solo 1" {
t.Fatalf("exclude: %s", got)
}
f = newFake(map[string]string{"TMUX": "x"})
f.solo = "1"
if state, _ := ToggleSync(f, true); state != "pane included in broadcast" || f.calls[1] != "tmux set-option -pu synchronize-panes" {
t.Fatalf("include: %q %v", state, f.calls)
}
if _, err := ToggleSync(newFake(map[string]string{"ZELLIJ": "0"}), true); err == nil || !strings.Contains(err.Error(), "whole tab") {
t.Fatalf("zellij: %v", err)
}
}