From 2069f0a3d7667edb42dd09d07785feb7a6e1c3e8 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 23 Dec 2025 22:45:33 -0800 Subject: [PATCH] Add tests for cmd/bd CLI to improve coverage (bd-llfl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added tests for: - daemon_config.go: ensureBeadsDir, getPIDFilePath, getLogFilePath, getSocketPathForPID - daemon_autostart.go: determineSocketPath, isDaemonRunningQuiet - activity.go: printEvent - cleanup.go: showCleanupDeprecationHint - upgrade.go: pluralize - wisp.go: formatTimeAgo - list.go: pinIndicator, sortIssues - hooks.go: FormatHookWarnings, isRebaseInProgress, hasBeadsJSONL - template.go: extractIDSuffix - thanks.go: getContributorsSorted Coverage improved from 22.5% to 23.1% for cmd/bd package. Most remaining untested code requires daemon/RPC operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/autostart_test.go | 55 ++++ cmd/bd/daemon_config_test.go | 144 +++++++++++ cmd/bd/utils_test.go | 484 +++++++++++++++++++++++++++++++++++ 3 files changed, 683 insertions(+) create mode 100644 cmd/bd/daemon_config_test.go create mode 100644 cmd/bd/utils_test.go diff --git a/cmd/bd/autostart_test.go b/cmd/bd/autostart_test.go index ee2bd84c..c913d4a0 100644 --- a/cmd/bd/autostart_test.go +++ b/cmd/bd/autostart_test.go @@ -245,3 +245,58 @@ func TestGetSocketPath(t *testing.T) { } }) } + +func TestDetermineSocketPath(t *testing.T) { + t.Run("returns same path passed in", func(t *testing.T) { + testPath := "/tmp/.beads/bd.sock" + result := determineSocketPath(testPath) + if result != testPath { + t.Errorf("determineSocketPath(%q) = %q, want %q", testPath, result, testPath) + } + }) + + t.Run("preserves empty path", func(t *testing.T) { + result := determineSocketPath("") + if result != "" { + t.Errorf("determineSocketPath(\"\") = %q, want empty string", result) + } + }) +} + +func TestIsDaemonRunningQuiet(t *testing.T) { + tmpDir := t.TempDir() + pidFile := filepath.Join(tmpDir, "daemon.pid") + + t.Run("returns false when PID file does not exist", func(t *testing.T) { + os.Remove(pidFile) + if isDaemonRunningQuiet(pidFile) { + t.Error("expected false when PID file does not exist") + } + }) + + t.Run("returns false when PID file is invalid", func(t *testing.T) { + if err := os.WriteFile(pidFile, []byte("not-a-number"), 0644); err != nil { + t.Fatalf("failed to create PID file: %v", err) + } + defer os.Remove(pidFile) + + if isDaemonRunningQuiet(pidFile) { + t.Error("expected false when PID file contains invalid content") + } + }) + + t.Run("returns false for non-existent PID", func(t *testing.T) { + // Use a PID that's very unlikely to exist + if err := os.WriteFile(pidFile, []byte("999999999"), 0644); err != nil { + t.Fatalf("failed to create PID file: %v", err) + } + defer os.Remove(pidFile) + + if isDaemonRunningQuiet(pidFile) { + t.Error("expected false for non-existent PID") + } + }) +} + +// Note: TestGetPIDFileForSocket, TestReadPIDFromFile, and TestIsPIDAlive +// are already defined in daemon_basics_test.go diff --git a/cmd/bd/daemon_config_test.go b/cmd/bd/daemon_config_test.go new file mode 100644 index 00000000..11e8aacd --- /dev/null +++ b/cmd/bd/daemon_config_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestGetSocketPathForPID tests socket path derivation from PID file path +func TestGetSocketPathForPID(t *testing.T) { + tests := []struct { + name string + pidFile string + expected string + }{ + { + name: "absolute path", + pidFile: "/home/user/.beads/daemon.pid", + expected: "/home/user/.beads/bd.sock", + }, + { + name: "relative path", + pidFile: ".beads/daemon.pid", + expected: ".beads/bd.sock", + }, + { + name: "nested path", + pidFile: "/var/run/beads/project/.beads/daemon.pid", + expected: "/var/run/beads/project/.beads/bd.sock", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getSocketPathForPID(tt.pidFile) + if result != tt.expected { + t.Errorf("getSocketPathForPID(%q) = %q, want %q", tt.pidFile, result, tt.expected) + } + }) + } +} + +// Note: TestGetEnvInt, TestGetEnvBool, TestBoolToFlag are already defined in +// daemon_rotation_test.go and autoimport_test.go respectively + +func TestEnsureBeadsDir(t *testing.T) { + // Save original dbPath and restore after + originalDbPath := dbPath + defer func() { dbPath = originalDbPath }() + + t.Run("creates directory when dbPath is set", func(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + dbPath = filepath.Join(beadsDir, "beads.db") + + result, err := ensureBeadsDir() + if err != nil { + t.Fatalf("ensureBeadsDir() error = %v", err) + } + + if result != beadsDir { + t.Errorf("ensureBeadsDir() = %q, want %q", result, beadsDir) + } + + // Verify directory was created + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + t.Error("directory was not created") + } + }) + + t.Run("returns existing directory", func(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + // Pre-create the directory + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("failed to create test directory: %v", err) + } + dbPath = filepath.Join(beadsDir, "beads.db") + + result, err := ensureBeadsDir() + if err != nil { + t.Fatalf("ensureBeadsDir() error = %v", err) + } + + if result != beadsDir { + t.Errorf("ensureBeadsDir() = %q, want %q", result, beadsDir) + } + }) +} + +func TestGetPIDFilePath(t *testing.T) { + // Save original dbPath and restore after + originalDbPath := dbPath + defer func() { dbPath = originalDbPath }() + + t.Run("returns correct PID file path", func(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + dbPath = filepath.Join(beadsDir, "beads.db") + + result, err := getPIDFilePath() + if err != nil { + t.Fatalf("getPIDFilePath() error = %v", err) + } + + expected := filepath.Join(beadsDir, "daemon.pid") + if result != expected { + t.Errorf("getPIDFilePath() = %q, want %q", result, expected) + } + }) +} + +func TestGetLogFilePath(t *testing.T) { + // Save original dbPath and restore after + originalDbPath := dbPath + defer func() { dbPath = originalDbPath }() + + t.Run("returns user-specified path when provided", func(t *testing.T) { + userPath := "/custom/path/daemon.log" + result, err := getLogFilePath(userPath) + if err != nil { + t.Fatalf("getLogFilePath() error = %v", err) + } + if result != userPath { + t.Errorf("getLogFilePath(%q) = %q, want %q", userPath, result, userPath) + } + }) + + t.Run("returns default path when empty", func(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + dbPath = filepath.Join(beadsDir, "beads.db") + + result, err := getLogFilePath("") + if err != nil { + t.Fatalf("getLogFilePath() error = %v", err) + } + + expected := filepath.Join(beadsDir, "daemon.log") + if result != expected { + t.Errorf("getLogFilePath(\"\") = %q, want %q", result, expected) + } + }) +} diff --git a/cmd/bd/utils_test.go b/cmd/bd/utils_test.go new file mode 100644 index 00000000..c26650e1 --- /dev/null +++ b/cmd/bd/utils_test.go @@ -0,0 +1,484 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "testing" + "time" + + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" +) + +func TestPluralize(t *testing.T) { + tests := []struct { + count int + expected string + }{ + {0, "s"}, + {1, ""}, + {2, "s"}, + {10, "s"}, + {100, "s"}, + {-1, "s"}, // Edge case + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("count=%d", tt.count), func(t *testing.T) { + result := pluralize(tt.count) + if result != tt.expected { + t.Errorf("pluralize(%d) = %q, want %q", tt.count, result, tt.expected) + } + }) + } +} + +func TestFormatTimeAgo(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + time time.Time + expected string + }{ + { + name: "just now", + time: now.Add(-30 * time.Second), + expected: "just now", + }, + { + name: "1 min ago", + time: now.Add(-1 * time.Minute), + expected: "1 min ago", + }, + { + name: "multiple minutes ago", + time: now.Add(-5 * time.Minute), + expected: "5 mins ago", + }, + { + name: "1 hour ago", + time: now.Add(-1 * time.Hour), + expected: "1 hour ago", + }, + { + name: "multiple hours ago", + time: now.Add(-3 * time.Hour), + expected: "3 hours ago", + }, + { + name: "1 day ago", + time: now.Add(-24 * time.Hour), + expected: "1 day ago", + }, + { + name: "multiple days ago", + time: now.Add(-3 * 24 * time.Hour), + expected: "3 days ago", + }, + { + name: "more than a week ago", + time: now.Add(-10 * 24 * time.Hour), + expected: now.Add(-10 * 24 * time.Hour).Format("2006-01-02"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatTimeAgo(tt.time) + if result != tt.expected { + t.Errorf("formatTimeAgo() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestPrintEvent(t *testing.T) { + // Capture stdout + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + event := rpc.MutationEvent{ + Type: rpc.MutationCreate, + IssueID: "bd-test123", + Timestamp: time.Date(2025, 1, 15, 14, 30, 0, 0, time.UTC), + } + + printEvent(event) + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + // Check that output contains expected elements + if len(output) == 0 { + t.Error("printEvent produced no output") + } + if !containsSubstring(output, "bd-test123") { + t.Errorf("printEvent output missing issue ID, got: %s", output) + } + if !containsSubstring(output, "created") { + t.Errorf("printEvent output missing 'created' message, got: %s", output) + } +} + +func TestShowCleanupDeprecationHint(t *testing.T) { + // Capture stderr + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + showCleanupDeprecationHint() + + w.Close() + os.Stderr = old + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + // Check that output contains expected elements + if len(output) == 0 { + t.Error("showCleanupDeprecationHint produced no output") + } + if !containsSubstring(output, "doctor --fix") { + t.Errorf("showCleanupDeprecationHint output missing 'doctor --fix', got: %s", output) + } +} + +// containsSubstring checks if haystack contains needle +func containsSubstring(haystack, needle string) bool { + for i := 0; i <= len(haystack)-len(needle); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} + +// Note: TestExtractPrefix is already defined in helpers_test.go + +func TestPinIndicator(t *testing.T) { + tests := []struct { + name string + pinned bool + expected string + }{ + { + name: "pinned issue", + pinned: true, + expected: "📌 ", + }, + { + name: "unpinned issue", + pinned: false, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + issue := &types.Issue{Pinned: tt.pinned} + result := pinIndicator(issue) + if result != tt.expected { + t.Errorf("pinIndicator() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestSortIssues(t *testing.T) { + now := time.Now() + yesterday := now.Add(-24 * time.Hour) + twoDaysAgo := now.Add(-48 * time.Hour) + + closedAt := now + closedYesterday := yesterday + + baseIssues := func() []*types.Issue { + return []*types.Issue{ + {ID: "bd-2", Title: "Beta", Priority: 2, CreatedAt: yesterday, UpdatedAt: yesterday, Status: "open"}, + {ID: "bd-1", Title: "Alpha", Priority: 1, CreatedAt: now, UpdatedAt: now, Status: "closed", ClosedAt: &closedAt}, + {ID: "bd-3", Title: "Gamma", Priority: 3, CreatedAt: twoDaysAgo, UpdatedAt: twoDaysAgo, Status: "in_progress", ClosedAt: &closedYesterday}, + } + } + + t.Run("sort by priority ascending", func(t *testing.T) { + issues := baseIssues() + sortIssues(issues, "priority", false) + if issues[0].Priority != 1 { + t.Errorf("expected priority 1 first, got %d", issues[0].Priority) + } + if issues[1].Priority != 2 { + t.Errorf("expected priority 2 second, got %d", issues[1].Priority) + } + if issues[2].Priority != 3 { + t.Errorf("expected priority 3 third, got %d", issues[2].Priority) + } + }) + + t.Run("sort by priority descending", func(t *testing.T) { + issues := baseIssues() + sortIssues(issues, "priority", true) + if issues[0].Priority != 3 { + t.Errorf("expected priority 3 first, got %d", issues[0].Priority) + } + }) + + t.Run("sort by title", func(t *testing.T) { + issues := baseIssues() + sortIssues(issues, "title", false) + if issues[0].Title != "Alpha" { + t.Errorf("expected 'Alpha' first, got %q", issues[0].Title) + } + }) + + t.Run("sort by ID", func(t *testing.T) { + issues := baseIssues() + sortIssues(issues, "id", false) + if issues[0].ID != "bd-1" { + t.Errorf("expected 'bd-1' first, got %q", issues[0].ID) + } + }) + + t.Run("sort by status", func(t *testing.T) { + issues := baseIssues() + sortIssues(issues, "status", false) + // closed < in_progress < open alphabetically + if issues[0].Status != "closed" { + t.Errorf("expected 'closed' first, got %q", issues[0].Status) + } + }) + + t.Run("empty sortBy does nothing", func(t *testing.T) { + issues := baseIssues() + origFirst := issues[0].ID + sortIssues(issues, "", false) + if issues[0].ID != origFirst { + t.Error("expected no sorting when sortBy is empty") + } + }) + + t.Run("unknown sortBy does nothing", func(t *testing.T) { + issues := baseIssues() + origFirst := issues[0].ID + sortIssues(issues, "unknown_field", false) + if issues[0].ID != origFirst { + t.Error("expected no sorting when sortBy is unknown") + } + }) +} + +func TestFormatHookWarnings(t *testing.T) { + t.Run("no warnings when all installed and current", func(t *testing.T) { + statuses := []HookStatus{ + {Name: "pre-commit", Installed: true, Outdated: false}, + {Name: "post-merge", Installed: true, Outdated: false}, + } + result := FormatHookWarnings(statuses) + if result != "" { + t.Errorf("expected empty string, got %q", result) + } + }) + + t.Run("warning when hooks missing", func(t *testing.T) { + statuses := []HookStatus{ + {Name: "pre-commit", Installed: false, Outdated: false}, + {Name: "post-merge", Installed: false, Outdated: false}, + } + result := FormatHookWarnings(statuses) + if !containsSubstring(result, "2 missing") { + t.Errorf("expected '2 missing' in output, got %q", result) + } + if !containsSubstring(result, "bd hooks install") { + t.Errorf("expected 'bd hooks install' in output, got %q", result) + } + }) + + t.Run("warning when hooks outdated", func(t *testing.T) { + statuses := []HookStatus{ + {Name: "pre-commit", Installed: true, Outdated: true}, + {Name: "post-merge", Installed: true, Outdated: true}, + } + result := FormatHookWarnings(statuses) + if !containsSubstring(result, "2 hooks") { + t.Errorf("expected '2 hooks' in output, got %q", result) + } + if !containsSubstring(result, "outdated") { + t.Errorf("expected 'outdated' in output, got %q", result) + } + }) + + t.Run("both missing and outdated", func(t *testing.T) { + statuses := []HookStatus{ + {Name: "pre-commit", Installed: false, Outdated: false}, + {Name: "post-merge", Installed: true, Outdated: true}, + } + result := FormatHookWarnings(statuses) + if !containsSubstring(result, "1 missing") { + t.Errorf("expected '1 missing' in output, got %q", result) + } + if !containsSubstring(result, "1 hooks") { + t.Errorf("expected '1 hooks' in output, got %q", result) + } + }) + + t.Run("empty statuses", func(t *testing.T) { + statuses := []HookStatus{} + result := FormatHookWarnings(statuses) + if result != "" { + t.Errorf("expected empty string for empty statuses, got %q", result) + } + }) +} + +func TestGetContributorsSorted(t *testing.T) { + contributors := getContributorsSorted() + + // Should have at least some contributors + if len(contributors) == 0 { + t.Error("expected non-empty contributors list") + } + + // First contributor should be the one with most commits (Steve Yegge) + if contributors[0] != "Steve Yegge" { + t.Errorf("expected 'Steve Yegge' first, got %q", contributors[0]) + } +} + +func TestExtractIDSuffix(t *testing.T) { + tests := []struct { + name string + id string + expected string + }{ + { + name: "hierarchical ID with dot", + id: "bd-xyz.1", + expected: "1", + }, + { + name: "nested hierarchical ID", + id: "bd-abc.step1.sub", + expected: "sub", + }, + { + name: "prefix-hash ID", + id: "patrol-abc123", + expected: "abc123", + }, + { + name: "simple ID", + id: "bd-123", + expected: "123", + }, + { + name: "no separators", + id: "standalone", + expected: "standalone", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractIDSuffix(tt.id) + if result != tt.expected { + t.Errorf("extractIDSuffix(%q) = %q, want %q", tt.id, result, tt.expected) + } + }) + } +} + +// Note: TestGetRelativeID is already defined in mol_test.go + +func TestIsRebaseInProgress(t *testing.T) { + // Create a temp directory to simulate a git repo + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp dir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Create .git directory + if err := os.MkdirAll(".git", 0755); err != nil { + t.Fatalf("failed to create .git dir: %v", err) + } + + t.Run("no rebase in progress", func(t *testing.T) { + if isRebaseInProgress() { + t.Error("expected false when no rebase markers exist") + } + }) + + t.Run("rebase-merge in progress", func(t *testing.T) { + if err := os.MkdirAll(".git/rebase-merge", 0755); err != nil { + t.Fatalf("failed to create rebase-merge dir: %v", err) + } + defer os.RemoveAll(".git/rebase-merge") + + if !isRebaseInProgress() { + t.Error("expected true when .git/rebase-merge exists") + } + }) + + t.Run("rebase-apply in progress", func(t *testing.T) { + if err := os.MkdirAll(".git/rebase-apply", 0755); err != nil { + t.Fatalf("failed to create rebase-apply dir: %v", err) + } + defer os.RemoveAll(".git/rebase-apply") + + if !isRebaseInProgress() { + t.Error("expected true when .git/rebase-apply exists") + } + }) +} + +func TestHasBeadsJSONL(t *testing.T) { + // Create a temp directory + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp dir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + t.Run("no JSONL files", func(t *testing.T) { + if hasBeadsJSONL() { + t.Error("expected false when no .beads directory exists") + } + }) + + t.Run("with issues.jsonl", func(t *testing.T) { + if err := os.MkdirAll(".beads", 0755); err != nil { + t.Fatalf("failed to create .beads dir: %v", err) + } + if err := os.WriteFile(".beads/issues.jsonl", []byte("{}"), 0644); err != nil { + t.Fatalf("failed to create issues.jsonl: %v", err) + } + defer os.RemoveAll(".beads") + + if !hasBeadsJSONL() { + t.Error("expected true when .beads/issues.jsonl exists") + } + }) + + t.Run("with beads.jsonl", func(t *testing.T) { + if err := os.MkdirAll(".beads", 0755); err != nil { + t.Fatalf("failed to create .beads dir: %v", err) + } + if err := os.WriteFile(".beads/beads.jsonl", []byte("{}"), 0644); err != nil { + t.Fatalf("failed to create beads.jsonl: %v", err) + } + defer os.RemoveAll(".beads") + + if !hasBeadsJSONL() { + t.Error("expected true when .beads/beads.jsonl exists") + } + }) +}