11
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
// Package tmux provides thin primitives around the tmux CLI.
|
||||
// Session orchestration (T11) is built on top of this package.
|
||||
package tmux
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// runner is the function used to invoke tmux subcommands.
|
||||
// Replaceable in tests via SetRunner/ResetRunner.
|
||||
var runner func(args ...string) ([]byte, error) = defaultRunner
|
||||
|
||||
func defaultRunner(args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("tmux", args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tmux %s: %w\n%s", args[0], err, out)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetRunner replaces the tmux runner (for tests).
|
||||
func SetRunner(r func(args ...string) ([]byte, error)) { runner = r }
|
||||
|
||||
// ResetRunner restores the default runner after a test.
|
||||
func ResetRunner() { runner = defaultRunner }
|
||||
|
||||
// Available reports whether tmux is present in PATH.
|
||||
func Available() bool {
|
||||
_, err := exec.LookPath("tmux")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// HasSession reports whether a tmux session with the given name exists.
|
||||
// A missing session is (false, nil); only genuine execution failures produce an error.
|
||||
func HasSession(name string) (bool, error) {
|
||||
_, err := runner("has-session", "-t", "="+name)
|
||||
if err != nil {
|
||||
// tmux exits non-zero when the session doesn't exist; treat that as (false, nil).
|
||||
// We can't inspect the exit code through the runner interface, so any error here
|
||||
// is assumed to mean "not found". Genuine failures (tmux not in PATH etc.) would
|
||||
// surface at Available() time before this is called.
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// EnsureSession creates the named session (detached) if it doesn't already exist.
|
||||
func EnsureSession(name string) error {
|
||||
ok, err := HasSession(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
_, err = runner("new-session", "-d", "-s", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// NewWindowOpts holds parameters for NewWindow.
|
||||
type NewWindowOpts struct {
|
||||
Session string
|
||||
Name string
|
||||
Dir string
|
||||
Command string
|
||||
}
|
||||
|
||||
// NewWindow opens a new window in the given session.
|
||||
// If Command is empty, the default shell is used.
|
||||
func NewWindow(opts NewWindowOpts) error {
|
||||
args := []string{"new-window", "-t", opts.Session + ":", "-n", opts.Name, "-c", opts.Dir}
|
||||
if opts.Command != "" {
|
||||
args = append(args, opts.Command)
|
||||
}
|
||||
_, err := runner(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// SelectWindow makes the named window the active window in the session.
|
||||
func SelectWindow(session, window string) error {
|
||||
_, err := runner("select-window", "-t", session+":"+window)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendKeys sends text to a window using the -l (literal) flag so the text is
|
||||
// never interpreted as key names. If enter is true, an additional Enter keystroke
|
||||
// is sent afterward.
|
||||
func SendKeys(session, window, text string, enter bool) error {
|
||||
if _, err := runner("send-keys", "-t", session+":"+window, "-l", "--", text); err != nil {
|
||||
return err
|
||||
}
|
||||
if enter {
|
||||
_, err := runner("send-keys", "-t", session+":"+window, "Enter")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KillWindow destroys the named window in the session.
|
||||
func KillWindow(session, window string) error {
|
||||
_, err := runner("kill-window", "-t", session+":"+window)
|
||||
return err
|
||||
}
|
||||
|
||||
// KillSession destroys the named session. Used for test cleanup.
|
||||
func KillSession(name string) error {
|
||||
_, err := runner("kill-session", "-t", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListWindows returns the names of all windows in the session.
|
||||
func ListWindows(session string) ([]string, error) {
|
||||
out, err := runner("list-windows", "-t", session, "-F", "#{window_name}")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := strings.TrimRight(string(out), "\n")
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return strings.Split(raw, "\n"), nil
|
||||
}
|
||||
|
||||
// WindowExists reports whether a window with the given name exists in the session.
|
||||
func WindowExists(session, window string) (bool, error) {
|
||||
wins, err := ListWindows(session)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, w := range wins {
|
||||
if w == window {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package tmux_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"superwork-tui/internal/tmux"
|
||||
)
|
||||
|
||||
// capturedRun records the args passed to the injected runner.
|
||||
type capturedRun struct {
|
||||
calls [][]string
|
||||
out []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *capturedRun) runner(args ...string) ([]byte, error) {
|
||||
c.calls = append(c.calls, append([]string(nil), args...))
|
||||
return c.out, c.err
|
||||
}
|
||||
|
||||
func assertArgs(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("arg count: got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("arg[%d]: got %q, want %q (full: %v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── unit tests (runner-injected) ──────────────────────────────────────────────
|
||||
|
||||
func TestHasSession_found(t *testing.T) {
|
||||
c := &capturedRun{out: nil, err: nil}
|
||||
tmux.SetRunner(c.runner)
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
ok, err := tmux.HasSession("mysession")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected true")
|
||||
}
|
||||
assertArgs(t, c.calls[0], []string{"has-session", "-t", "=mysession"})
|
||||
}
|
||||
|
||||
func TestHasSession_notFound(t *testing.T) {
|
||||
c := &capturedRun{out: nil, err: fmt.Errorf("exit status 1")}
|
||||
tmux.SetRunner(c.runner)
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
ok, err := tmux.HasSession("nosuch")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSession_creates(t *testing.T) {
|
||||
c := &capturedRun{}
|
||||
tmux.SetRunner(c.runner)
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
// first call = has-session → not found; second call = new-session
|
||||
c.err = fmt.Errorf("exit status 1")
|
||||
called := 0
|
||||
tmux.SetRunner(func(args ...string) ([]byte, error) {
|
||||
called++
|
||||
c.calls = append(c.calls, append([]string(nil), args...))
|
||||
if called == 1 {
|
||||
return nil, fmt.Errorf("exit status 1") // has-session: not found
|
||||
}
|
||||
return nil, nil // new-session: ok
|
||||
})
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
if err := tmux.EnsureSession("mysession"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertArgs(t, c.calls[0], []string{"has-session", "-t", "=mysession"})
|
||||
assertArgs(t, c.calls[1], []string{"new-session", "-d", "-s", "mysession"})
|
||||
}
|
||||
|
||||
func TestEnsureSession_alreadyExists(t *testing.T) {
|
||||
called := 0
|
||||
tmux.SetRunner(func(args ...string) ([]byte, error) {
|
||||
called++
|
||||
return nil, nil // has-session: found
|
||||
})
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
if err := tmux.EnsureSession("existing"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if called != 1 {
|
||||
t.Fatalf("expected 1 call (has-session only), got %d", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWindow_withCommand(t *testing.T) {
|
||||
c := &capturedRun{}
|
||||
tmux.SetRunner(c.runner)
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
err := tmux.NewWindow(tmux.NewWindowOpts{
|
||||
Session: "s",
|
||||
Name: "123-impl",
|
||||
Dir: "/some/path",
|
||||
Command: "bash",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertArgs(t, c.calls[0], []string{"new-window", "-t", "s:", "-n", "123-impl", "-c", "/some/path", "bash"})
|
||||
}
|
||||
|
||||
func TestNewWindow_noCommand(t *testing.T) {
|
||||
c := &capturedRun{}
|
||||
tmux.SetRunner(c.runner)
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
err := tmux.NewWindow(tmux.NewWindowOpts{
|
||||
Session: "s",
|
||||
Name: "124-brain",
|
||||
Dir: "/other",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertArgs(t, c.calls[0], []string{"new-window", "-t", "s:", "-n", "124-brain", "-c", "/other"})
|
||||
}
|
||||
|
||||
func TestSelectWindow(t *testing.T) {
|
||||
c := &capturedRun{}
|
||||
tmux.SetRunner(c.runner)
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
if err := tmux.SelectWindow("s", "win"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertArgs(t, c.calls[0], []string{"select-window", "-t", "s:win"})
|
||||
}
|
||||
|
||||
func TestSendKeys_withEnter(t *testing.T) {
|
||||
var calls [][]string
|
||||
tmux.SetRunner(func(args ...string) ([]byte, error) {
|
||||
calls = append(calls, append([]string(nil), args...))
|
||||
return nil, nil
|
||||
})
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
if err := tmux.SendKeys("s", "win", "echo hi", true); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls, got %d", len(calls))
|
||||
}
|
||||
assertArgs(t, calls[0], []string{"send-keys", "-t", "s:win", "-l", "--", "echo hi"})
|
||||
assertArgs(t, calls[1], []string{"send-keys", "-t", "s:win", "Enter"})
|
||||
}
|
||||
|
||||
func TestSendKeys_noEnter(t *testing.T) {
|
||||
var calls [][]string
|
||||
tmux.SetRunner(func(args ...string) ([]byte, error) {
|
||||
calls = append(calls, append([]string(nil), args...))
|
||||
return nil, nil
|
||||
})
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
if err := tmux.SendKeys("s", "win", "some text", false); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
assertArgs(t, calls[0], []string{"send-keys", "-t", "s:win", "-l", "--", "some text"})
|
||||
}
|
||||
|
||||
func TestKillWindow(t *testing.T) {
|
||||
c := &capturedRun{}
|
||||
tmux.SetRunner(c.runner)
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
if err := tmux.KillWindow("s", "win"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertArgs(t, c.calls[0], []string{"kill-window", "-t", "s:win"})
|
||||
}
|
||||
|
||||
func TestListWindows(t *testing.T) {
|
||||
c := &capturedRun{out: []byte("win1\nwin2\nwin3\n")}
|
||||
tmux.SetRunner(c.runner)
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
wins, err := tmux.ListWindows("s")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertArgs(t, c.calls[0], []string{"list-windows", "-t", "s", "-F", "#{window_name}"})
|
||||
if len(wins) != 3 || wins[0] != "win1" || wins[1] != "win2" || wins[2] != "win3" {
|
||||
t.Fatalf("unexpected windows: %v", wins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowExists_true(t *testing.T) {
|
||||
tmux.SetRunner(func(args ...string) ([]byte, error) {
|
||||
return []byte("win1\ntarget\nwin3\n"), nil
|
||||
})
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
ok, err := tmux.WindowExists("s", "target")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowExists_false(t *testing.T) {
|
||||
tmux.SetRunner(func(args ...string) ([]byte, error) {
|
||||
return []byte("win1\nwin2\n"), nil
|
||||
})
|
||||
defer tmux.ResetRunner()
|
||||
|
||||
ok, err := tmux.WindowExists("s", "ghost")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected false")
|
||||
}
|
||||
}
|
||||
|
||||
// ── integration test ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestIntegration(t *testing.T) {
|
||||
if !tmux.Available() {
|
||||
t.Skip("tmux not in PATH")
|
||||
}
|
||||
|
||||
session := fmt.Sprintf("swtest-%d", os.Getpid())
|
||||
t.Cleanup(func() {
|
||||
// best-effort cleanup; ignore error (session may already be gone)
|
||||
_ = tmux.KillSession(session)
|
||||
})
|
||||
|
||||
if err := tmux.EnsureSession(session); err != nil {
|
||||
t.Fatalf("EnsureSession: %v", err)
|
||||
}
|
||||
|
||||
const winName = "testwin"
|
||||
if err := tmux.NewWindow(tmux.NewWindowOpts{Session: session, Name: winName, Dir: "/tmp"}); err != nil {
|
||||
t.Fatalf("NewWindow: %v", err)
|
||||
}
|
||||
|
||||
wins, err := tmux.ListWindows(session)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWindows: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, w := range wins {
|
||||
if w == winName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("ListWindows: %q not in %v", winName, wins)
|
||||
}
|
||||
|
||||
ok, err := tmux.WindowExists(session, winName)
|
||||
if err != nil {
|
||||
t.Fatalf("WindowExists: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("WindowExists: expected true")
|
||||
}
|
||||
|
||||
if err := tmux.SendKeys(session, winName, "echo hello", true); err != nil {
|
||||
t.Fatalf("SendKeys: %v", err)
|
||||
}
|
||||
|
||||
if err := tmux.KillWindow(session, winName); err != nil {
|
||||
t.Fatalf("KillWindow: %v", err)
|
||||
}
|
||||
|
||||
ok, err = tmux.WindowExists(session, winName)
|
||||
if err != nil {
|
||||
t.Fatalf("WindowExists after kill: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("WindowExists after kill: expected false")
|
||||
}
|
||||
|
||||
// verify the session name contains our prefix (sanity check on naming)
|
||||
if !strings.HasPrefix(session, "swtest-") {
|
||||
t.Fatalf("unexpected session name: %s", session)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user