Claude Code can report its pane command as "node", "claude", or a version number like "2.0.76". Previously only "node" was detected, causing healthy sessions to be incorrectly identified as zombies and killed during daemon heartbeat recovery. This fix detects all three patterns to prevent witness sessions from being killed every 3 minutes. Based on michaellady's work in PR #174. Co-Authored-By: michaellady <michaellady@users.noreply.github.com> Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
463 lines
11 KiB
Go
463 lines
11 KiB
Go
package tmux
|
|
|
|
import (
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func hasTmux() bool {
|
|
_, err := exec.LookPath("tmux")
|
|
return err == nil
|
|
}
|
|
|
|
func TestListSessionsNoServer(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
sessions, err := tm.ListSessions()
|
|
// Should not error even if no server running
|
|
if err != nil {
|
|
t.Fatalf("ListSessions: %v", err)
|
|
}
|
|
// Result may be nil or empty slice
|
|
_ = sessions
|
|
}
|
|
|
|
func TestHasSessionNoServer(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
has, err := tm.HasSession("nonexistent-session-xyz")
|
|
if err != nil {
|
|
t.Fatalf("HasSession: %v", err)
|
|
}
|
|
if has {
|
|
t.Error("expected session to not exist")
|
|
}
|
|
}
|
|
|
|
func TestSessionLifecycle(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
sessionName := "gt-test-session-" + t.Name()
|
|
|
|
// Clean up any existing session
|
|
_ = tm.KillSession(sessionName)
|
|
|
|
// Create session
|
|
if err := tm.NewSession(sessionName, ""); err != nil {
|
|
t.Fatalf("NewSession: %v", err)
|
|
}
|
|
defer func() { _ = tm.KillSession(sessionName) }()
|
|
|
|
// Verify exists
|
|
has, err := tm.HasSession(sessionName)
|
|
if err != nil {
|
|
t.Fatalf("HasSession: %v", err)
|
|
}
|
|
if !has {
|
|
t.Error("expected session to exist after creation")
|
|
}
|
|
|
|
// List should include it
|
|
sessions, err := tm.ListSessions()
|
|
if err != nil {
|
|
t.Fatalf("ListSessions: %v", err)
|
|
}
|
|
found := false
|
|
for _, s := range sessions {
|
|
if s == sessionName {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("session not found in list")
|
|
}
|
|
|
|
// Kill session
|
|
if err := tm.KillSession(sessionName); err != nil {
|
|
t.Fatalf("KillSession: %v", err)
|
|
}
|
|
|
|
// Verify gone
|
|
has, err = tm.HasSession(sessionName)
|
|
if err != nil {
|
|
t.Fatalf("HasSession after kill: %v", err)
|
|
}
|
|
if has {
|
|
t.Error("expected session to not exist after kill")
|
|
}
|
|
}
|
|
|
|
func TestDuplicateSession(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
sessionName := "gt-test-dup-" + t.Name()
|
|
|
|
// Clean up any existing session
|
|
_ = tm.KillSession(sessionName)
|
|
|
|
// Create session
|
|
if err := tm.NewSession(sessionName, ""); err != nil {
|
|
t.Fatalf("NewSession: %v", err)
|
|
}
|
|
defer func() { _ = tm.KillSession(sessionName) }()
|
|
|
|
// Try to create duplicate
|
|
err := tm.NewSession(sessionName, "")
|
|
if err != ErrSessionExists {
|
|
t.Errorf("expected ErrSessionExists, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSendKeysAndCapture(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
sessionName := "gt-test-keys-" + t.Name()
|
|
|
|
// Clean up any existing session
|
|
_ = tm.KillSession(sessionName)
|
|
|
|
// Create session
|
|
if err := tm.NewSession(sessionName, ""); err != nil {
|
|
t.Fatalf("NewSession: %v", err)
|
|
}
|
|
defer func() { _ = tm.KillSession(sessionName) }()
|
|
|
|
// Send echo command
|
|
if err := tm.SendKeys(sessionName, "echo HELLO_TEST_MARKER"); err != nil {
|
|
t.Fatalf("SendKeys: %v", err)
|
|
}
|
|
|
|
// Give it a moment to execute
|
|
// In real tests you'd wait for output, but for basic test we just capture
|
|
output, err := tm.CapturePane(sessionName, 50)
|
|
if err != nil {
|
|
t.Fatalf("CapturePane: %v", err)
|
|
}
|
|
|
|
// Should contain our marker (might not if shell is slow, but usually works)
|
|
if !strings.Contains(output, "echo HELLO_TEST_MARKER") {
|
|
t.Logf("captured output: %s", output)
|
|
// Don't fail, just note - timing issues possible
|
|
}
|
|
}
|
|
|
|
func TestGetSessionInfo(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
sessionName := "gt-test-info-" + t.Name()
|
|
|
|
// Clean up any existing session
|
|
_ = tm.KillSession(sessionName)
|
|
|
|
// Create session
|
|
if err := tm.NewSession(sessionName, ""); err != nil {
|
|
t.Fatalf("NewSession: %v", err)
|
|
}
|
|
defer func() { _ = tm.KillSession(sessionName) }()
|
|
|
|
info, err := tm.GetSessionInfo(sessionName)
|
|
if err != nil {
|
|
t.Fatalf("GetSessionInfo: %v", err)
|
|
}
|
|
|
|
if info.Name != sessionName {
|
|
t.Errorf("Name = %q, want %q", info.Name, sessionName)
|
|
}
|
|
if info.Windows < 1 {
|
|
t.Errorf("Windows = %d, want >= 1", info.Windows)
|
|
}
|
|
}
|
|
|
|
func TestWrapError(t *testing.T) {
|
|
tm := NewTmux()
|
|
|
|
tests := []struct {
|
|
stderr string
|
|
want error
|
|
}{
|
|
{"no server running on /tmp/tmux-...", ErrNoServer},
|
|
{"error connecting to /tmp/tmux-...", ErrNoServer},
|
|
{"duplicate session: test", ErrSessionExists},
|
|
{"session not found: test", ErrSessionNotFound},
|
|
{"can't find session: test", ErrSessionNotFound},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
err := tm.wrapError(nil, tt.stderr, []string{"test"})
|
|
if err != tt.want {
|
|
t.Errorf("wrapError(%q) = %v, want %v", tt.stderr, err, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEnsureSessionFresh_NoExistingSession(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
sessionName := "gt-test-fresh-" + t.Name()
|
|
|
|
// Clean up any existing session
|
|
_ = tm.KillSession(sessionName)
|
|
|
|
// EnsureSessionFresh should create a new session
|
|
if err := tm.EnsureSessionFresh(sessionName, ""); err != nil {
|
|
t.Fatalf("EnsureSessionFresh: %v", err)
|
|
}
|
|
defer func() { _ = tm.KillSession(sessionName) }()
|
|
|
|
// Verify session exists
|
|
has, err := tm.HasSession(sessionName)
|
|
if err != nil {
|
|
t.Fatalf("HasSession: %v", err)
|
|
}
|
|
if !has {
|
|
t.Error("expected session to exist after EnsureSessionFresh")
|
|
}
|
|
}
|
|
|
|
func TestEnsureSessionFresh_ZombieSession(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
sessionName := "gt-test-zombie-" + t.Name()
|
|
|
|
// Clean up any existing session
|
|
_ = tm.KillSession(sessionName)
|
|
|
|
// Create a zombie session (session exists but no Claude/node running)
|
|
// A normal tmux session with bash/zsh is a "zombie" for our purposes
|
|
if err := tm.NewSession(sessionName, ""); err != nil {
|
|
t.Fatalf("NewSession: %v", err)
|
|
}
|
|
defer func() { _ = tm.KillSession(sessionName) }()
|
|
|
|
// Verify it's a zombie (not running Claude/node)
|
|
if tm.IsClaudeRunning(sessionName) {
|
|
t.Skip("session unexpectedly has Claude running - can't test zombie case")
|
|
}
|
|
|
|
// Verify generic agent check also treats it as not running (shell session)
|
|
if tm.IsAgentRunning(sessionName) {
|
|
t.Fatalf("expected IsAgentRunning(%q) to be false for a fresh shell session", sessionName)
|
|
}
|
|
|
|
// EnsureSessionFresh should kill the zombie and create fresh session
|
|
// This should NOT error with "session already exists"
|
|
if err := tm.EnsureSessionFresh(sessionName, ""); err != nil {
|
|
t.Fatalf("EnsureSessionFresh on zombie: %v", err)
|
|
}
|
|
|
|
// Session should still exist
|
|
has, err := tm.HasSession(sessionName)
|
|
if err != nil {
|
|
t.Fatalf("HasSession: %v", err)
|
|
}
|
|
if !has {
|
|
t.Error("expected session to exist after EnsureSessionFresh on zombie")
|
|
}
|
|
}
|
|
|
|
func TestEnsureSessionFresh_IdempotentOnZombie(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
sessionName := "gt-test-idem-" + t.Name()
|
|
|
|
// Clean up any existing session
|
|
_ = tm.KillSession(sessionName)
|
|
|
|
// Call EnsureSessionFresh multiple times - should work each time
|
|
for i := 0; i < 3; i++ {
|
|
if err := tm.EnsureSessionFresh(sessionName, ""); err != nil {
|
|
t.Fatalf("EnsureSessionFresh attempt %d: %v", i+1, err)
|
|
}
|
|
}
|
|
defer func() { _ = tm.KillSession(sessionName) }()
|
|
|
|
// Session should exist
|
|
has, err := tm.HasSession(sessionName)
|
|
if err != nil {
|
|
t.Fatalf("HasSession: %v", err)
|
|
}
|
|
if !has {
|
|
t.Error("expected session to exist after multiple EnsureSessionFresh calls")
|
|
}
|
|
}
|
|
|
|
func TestIsAgentRunning(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
sessionName := "gt-test-agent-" + t.Name()
|
|
|
|
// Clean up any existing session
|
|
_ = tm.KillSession(sessionName)
|
|
|
|
// Create session (will run default shell)
|
|
if err := tm.NewSession(sessionName, ""); err != nil {
|
|
t.Fatalf("NewSession: %v", err)
|
|
}
|
|
defer func() { _ = tm.KillSession(sessionName) }()
|
|
|
|
// Get the current pane command (should be bash/zsh/etc)
|
|
cmd, err := tm.GetPaneCommand(sessionName)
|
|
if err != nil {
|
|
t.Fatalf("GetPaneCommand: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
processNames []string
|
|
wantRunning bool
|
|
}{
|
|
{
|
|
name: "empty process list",
|
|
processNames: []string{},
|
|
wantRunning: false,
|
|
},
|
|
{
|
|
name: "matching shell process",
|
|
processNames: []string{cmd}, // Current shell
|
|
wantRunning: true,
|
|
},
|
|
{
|
|
name: "claude agent (node) - not running",
|
|
processNames: []string{"node"},
|
|
wantRunning: cmd == "node", // Only true if shell happens to be node
|
|
},
|
|
{
|
|
name: "gemini agent - not running",
|
|
processNames: []string{"gemini"},
|
|
wantRunning: cmd == "gemini",
|
|
},
|
|
{
|
|
name: "cursor agent - not running",
|
|
processNames: []string{"cursor-agent"},
|
|
wantRunning: cmd == "cursor-agent",
|
|
},
|
|
{
|
|
name: "multiple process names with match",
|
|
processNames: []string{"nonexistent", cmd, "also-nonexistent"},
|
|
wantRunning: true,
|
|
},
|
|
{
|
|
name: "multiple process names without match",
|
|
processNames: []string{"nonexistent1", "nonexistent2"},
|
|
wantRunning: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := tm.IsAgentRunning(sessionName, tt.processNames...)
|
|
if got != tt.wantRunning {
|
|
t.Errorf("IsAgentRunning(%q, %v) = %v, want %v (current cmd: %q)",
|
|
sessionName, tt.processNames, got, tt.wantRunning, cmd)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsAgentRunning_NonexistentSession(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
|
|
// IsAgentRunning on nonexistent session should return false, not error
|
|
got := tm.IsAgentRunning("nonexistent-session-xyz", "node", "gemini", "cursor-agent")
|
|
if got {
|
|
t.Error("IsAgentRunning on nonexistent session should return false")
|
|
}
|
|
}
|
|
|
|
func TestIsClaudeRunning(t *testing.T) {
|
|
if !hasTmux() {
|
|
t.Skip("tmux not installed")
|
|
}
|
|
|
|
tm := NewTmux()
|
|
sessionName := "gt-test-claude-" + t.Name()
|
|
|
|
// Clean up any existing session
|
|
_ = tm.KillSession(sessionName)
|
|
|
|
// Create session (will run default shell, not Claude)
|
|
if err := tm.NewSession(sessionName, ""); err != nil {
|
|
t.Fatalf("NewSession: %v", err)
|
|
}
|
|
defer func() { _ = tm.KillSession(sessionName) }()
|
|
|
|
// IsClaudeRunning should be false (shell is running, not node/claude)
|
|
cmd, _ := tm.GetPaneCommand(sessionName)
|
|
wantRunning := cmd == "node" || cmd == "claude"
|
|
|
|
if got := tm.IsClaudeRunning(sessionName); got != wantRunning {
|
|
t.Errorf("IsClaudeRunning() = %v, want %v (pane cmd: %q)", got, wantRunning, cmd)
|
|
}
|
|
}
|
|
|
|
func TestIsClaudeRunning_VersionPattern(t *testing.T) {
|
|
// Test the version pattern regex matching directly
|
|
// Since we can't easily mock the pane command, test the pattern logic
|
|
tests := []struct {
|
|
cmd string
|
|
want bool
|
|
}{
|
|
{"node", true},
|
|
{"claude", true},
|
|
{"2.0.76", true},
|
|
{"1.2.3", true},
|
|
{"10.20.30", true},
|
|
{"bash", false},
|
|
{"zsh", false},
|
|
{"", false},
|
|
{"v2.0.76", false}, // version with 'v' prefix shouldn't match
|
|
{"2.0", false}, // incomplete version
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.cmd, func(t *testing.T) {
|
|
// Check if it matches node/claude directly
|
|
isKnownCmd := tt.cmd == "node" || tt.cmd == "claude"
|
|
// Check version pattern
|
|
matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+`, tt.cmd)
|
|
|
|
got := isKnownCmd || matched
|
|
if got != tt.want {
|
|
t.Errorf("IsClaudeRunning logic for %q = %v, want %v", tt.cmd, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|