test: add cmd/bd helper coverage and stabilize test runner

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Jordan Hubbard
2025-12-26 17:55:51 -04:00
parent 28f2c76589
commit 0b6ec57928
8 changed files with 318 additions and 4 deletions

View File

@@ -0,0 +1,107 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
func TestTouchDatabaseFile_UsesJSONLMtime(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "beads.db")
jsonlPath := filepath.Join(tmp, "issues.jsonl")
if err := os.WriteFile(dbPath, []byte(""), 0o600); err != nil {
t.Fatalf("WriteFile db: %v", err)
}
if err := os.WriteFile(jsonlPath, []byte("{}\n"), 0o600); err != nil {
t.Fatalf("WriteFile jsonl: %v", err)
}
jsonlTime := time.Now().Add(2 * time.Second)
if err := os.Chtimes(jsonlPath, jsonlTime, jsonlTime); err != nil {
t.Fatalf("Chtimes jsonl: %v", err)
}
if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil {
t.Fatalf("TouchDatabaseFile: %v", err)
}
info, err := os.Stat(dbPath)
if err != nil {
t.Fatalf("Stat db: %v", err)
}
if info.ModTime().Before(jsonlTime) {
t.Fatalf("db mtime %v should be >= jsonl mtime %v", info.ModTime(), jsonlTime)
}
}
func TestImportDetectPrefixFromIssues(t *testing.T) {
if detectPrefixFromIssues(nil) != "" {
t.Fatalf("expected empty")
}
issues := []*types.Issue{
{ID: "test-1"},
{ID: "test-2"},
{ID: "other-1"},
}
if got := detectPrefixFromIssues(issues); got != "test" {
t.Fatalf("got %q, want %q", got, "test")
}
}
func TestCountLines(t *testing.T) {
tmp := t.TempDir()
p := filepath.Join(tmp, "f.txt")
if err := os.WriteFile(p, []byte("a\n\nb\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if got := countLines(p); got != 3 {
t.Fatalf("countLines=%d, want 3", got)
}
}
func TestCheckUncommittedChanges_Warns(t *testing.T) {
_, cleanup := setupGitRepo(t)
defer cleanup()
if err := os.WriteFile("issues.jsonl", []byte("{\"id\":\"test-1\"}\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_ = execCmd(t, "git", "add", "issues.jsonl")
_ = execCmd(t, "git", "commit", "-m", "add issues")
// Modify without committing.
if err := os.WriteFile("issues.jsonl", []byte("{\"id\":\"test-1\"}\n{\"id\":\"test-2\"}\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
warn := captureStderr(t, func() {
checkUncommittedChanges("issues.jsonl", &ImportResult{})
})
if !strings.Contains(warn, "uncommitted changes") {
t.Fatalf("expected warning, got: %q", warn)
}
noWarn := captureStderr(t, func() {
checkUncommittedChanges("issues.jsonl", &ImportResult{Created: 1})
})
if noWarn != "" {
t.Fatalf("expected no warning, got: %q", noWarn)
}
}
func execCmd(t *testing.T, name string, args ...string) string {
t.Helper()
out, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
t.Fatalf("%s %v failed: %v\n%s", name, args, err, out)
}
return string(out)
}

116
cmd/bd/list_helpers_test.go Normal file
View File

@@ -0,0 +1,116 @@
package main
import (
"strings"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
func TestListParseTimeFlag(t *testing.T) {
cases := []string{
"2025-12-26",
"2025-12-26T12:34:56",
"2025-12-26 12:34:56",
time.DateOnly,
time.RFC3339,
}
for _, c := range cases {
// Just make sure we accept the expected formats.
var s string
switch c {
case time.DateOnly:
s = "2025-12-26"
case time.RFC3339:
s = "2025-12-26T12:34:56Z"
default:
s = c
}
got, err := parseTimeFlag(s)
if err != nil {
t.Fatalf("parseTimeFlag(%q) error: %v", s, err)
}
if got.Year() != 2025 {
t.Fatalf("parseTimeFlag(%q) year=%d, want 2025", s, got.Year())
}
}
if _, err := parseTimeFlag("not-a-date"); err == nil {
t.Fatalf("expected error")
}
}
func TestListPinIndicator(t *testing.T) {
if pinIndicator(&types.Issue{Pinned: true}) == "" {
t.Fatalf("expected pin indicator")
}
if pinIndicator(&types.Issue{Pinned: false}) != "" {
t.Fatalf("expected empty pin indicator")
}
}
func TestListFormatPrettyIssue_BadgesAndDefaults(t *testing.T) {
iss := &types.Issue{ID: "bd-1", Title: "Hello", Status: "wat", Priority: 99, IssueType: "bug"}
out := formatPrettyIssue(iss)
if !strings.Contains(out, "bd-1") || !strings.Contains(out, "Hello") {
t.Fatalf("unexpected output: %q", out)
}
if !strings.Contains(out, "[BUG]") {
t.Fatalf("expected BUG badge: %q", out)
}
}
func TestListBuildIssueTree_ParentChildByDotID(t *testing.T) {
parent := &types.Issue{ID: "bd-1", Title: "Parent", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
child := &types.Issue{ID: "bd-1.1", Title: "Child", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
orphan := &types.Issue{ID: "bd-2.1", Title: "Orphan", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
roots, children := buildIssueTree([]*types.Issue{child, parent, orphan})
if len(children["bd-1"]) != 1 || children["bd-1"][0].ID != "bd-1.1" {
t.Fatalf("expected bd-1 to have bd-1.1 child: %+v", children)
}
if len(roots) != 2 {
t.Fatalf("expected 2 roots (parent + orphan), got %d", len(roots))
}
}
func TestListSortIssues_ClosedNilLast(t *testing.T) {
t1 := time.Now().Add(-2 * time.Hour)
t2 := time.Now().Add(-1 * time.Hour)
closedOld := &types.Issue{ID: "bd-1", ClosedAt: &t1}
closedNew := &types.Issue{ID: "bd-2", ClosedAt: &t2}
open := &types.Issue{ID: "bd-3", ClosedAt: nil}
issues := []*types.Issue{open, closedOld, closedNew}
sortIssues(issues, "closed", false)
if issues[0].ID != "bd-2" || issues[1].ID != "bd-1" || issues[2].ID != "bd-3" {
t.Fatalf("unexpected order: %s, %s, %s", issues[0].ID, issues[1].ID, issues[2].ID)
}
}
func TestListDisplayPrettyList(t *testing.T) {
out := captureStdout(t, func() error {
displayPrettyList(nil, false)
return nil
})
if !strings.Contains(out, "No issues found") {
t.Fatalf("unexpected output: %q", out)
}
issues := []*types.Issue{
{ID: "bd-1", Title: "A", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask},
{ID: "bd-2", Title: "B", Status: types.StatusInProgress, Priority: 1, IssueType: types.TypeFeature},
{ID: "bd-1.1", Title: "C", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask},
}
out = captureStdout(t, func() error {
displayPrettyList(issues, false)
return nil
})
if !strings.Contains(out, "bd-1") || !strings.Contains(out, "bd-1.1") || !strings.Contains(out, "Total:") {
t.Fatalf("unexpected output: %q", out)
}
}

View File

@@ -9,6 +9,7 @@ import (
"runtime"
"testing"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
@@ -90,6 +91,7 @@ func testFreshCloneAutoImport(t *testing.T) {
// Test checkGitForIssues detects issues.jsonl
t.Chdir(dir)
git.ResetCaches()
count, path, gitRef := checkGitForIssues()
if count != 1 {
@@ -169,6 +171,7 @@ func testDatabaseRemovalScenario(t *testing.T) {
// Change to test directory
t.Chdir(dir)
git.ResetCaches()
// Test checkGitForIssues finds issues.jsonl (canonical name)
count, path, gitRef := checkGitForIssues()
@@ -247,6 +250,7 @@ func testLegacyFilenameSupport(t *testing.T) {
// Change to test directory
t.Chdir(dir)
git.ResetCaches()
// Test checkGitForIssues finds issues.jsonl
count, path, gitRef := checkGitForIssues()
@@ -323,6 +327,7 @@ func testPrecedenceTest(t *testing.T) {
// Change to test directory
t.Chdir(dir)
git.ResetCaches()
// Test checkGitForIssues prefers issues.jsonl
count, path, _ := checkGitForIssues()
@@ -369,6 +374,7 @@ func testInitSafetyCheck(t *testing.T) {
// Change to test directory
t.Chdir(dir)
git.ResetCaches()
// Create empty database (simulating failed import)
dbPath := filepath.Join(beadsDir, "test.db")

View File

@@ -0,0 +1,71 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/beads/internal/config"
)
func TestBuildGitCommitArgs_ConfigOptions(t *testing.T) {
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize: %v", err)
}
config.Set("git.author", "Test User <test@example.com>")
config.Set("git.no-gpg-sign", true)
args := buildGitCommitArgs("/repo", "hello", "--", ".beads")
joined := strings.Join(args, " ")
if !strings.Contains(joined, "--author") {
t.Fatalf("expected --author in args: %v", args)
}
if !strings.Contains(joined, "--no-gpg-sign") {
t.Fatalf("expected --no-gpg-sign in args: %v", args)
}
if !strings.Contains(joined, "-m hello") {
t.Fatalf("expected message in args: %v", args)
}
}
func TestGitCommitBeadsDir_PathspecDoesNotCommitOtherStagedFiles(t *testing.T) {
_, cleanup := setupGitRepo(t)
defer cleanup()
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize: %v", err)
}
if err := os.MkdirAll(".beads", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
// Stage an unrelated file before running gitCommitBeadsDir.
if err := os.WriteFile("other.txt", []byte("x\n"), 0o600); err != nil {
t.Fatalf("WriteFile other: %v", err)
}
_ = exec.Command("git", "add", "other.txt").Run()
// Create a beads sync file to commit.
issuesPath := filepath.Join(".beads", "issues.jsonl")
if err := os.WriteFile(issuesPath, []byte("{\"id\":\"test-1\"}\n"), 0o600); err != nil {
t.Fatalf("WriteFile issues: %v", err)
}
ctx := context.Background()
if err := gitCommitBeadsDir(ctx, "beads commit"); err != nil {
t.Fatalf("gitCommitBeadsDir: %v", err)
}
// other.txt should still be staged after the beads-only commit.
out, err := exec.Command("git", "diff", "--cached", "--name-only").CombinedOutput()
if err != nil {
t.Fatalf("git diff --cached: %v\n%s", err, out)
}
if strings.TrimSpace(string(out)) != "other.txt" {
t.Fatalf("expected other.txt still staged, got: %q", out)
}
}

View File

@@ -5,6 +5,8 @@ import (
"os/exec"
"testing"
"time"
"github.com/steveyegge/beads/internal/git"
)
// waitFor repeatedly evaluates pred until it returns true or timeout expires.
@@ -42,6 +44,7 @@ func setupGitRepo(t *testing.T) (repoPath string, cleanup func()) {
_ = os.Chdir(originalWd)
t.Fatalf("failed to init git repo: %v", err)
}
git.ResetCaches()
// Configure git
_ = exec.Command("git", "config", "user.email", "test@test.com").Run()
@@ -85,6 +88,7 @@ func setupGitRepoWithBranch(t *testing.T, branch string) (repoPath string, clean
_ = os.Chdir(originalWd)
t.Fatalf("failed to init git repo: %v", err)
}
git.ResetCaches()
// Configure git
_ = exec.Command("git", "config", "user.email", "test@test.com").Run()

View File

@@ -7,6 +7,7 @@ import (
"testing"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/git"
// Import SQLite driver for test database creation
_ "github.com/ncruces/go-sqlite3/driver"
@@ -78,6 +79,7 @@ func TestShouldDisableDaemonForWorktree(t *testing.T) {
if err := os.Chdir(worktreeDir); err != nil {
t.Fatalf("Failed to change to worktree dir: %v", err)
}
git.ResetCaches()
// No sync-branch configured
os.Unsetenv("BEADS_SYNC_BRANCH")
@@ -113,6 +115,7 @@ func TestShouldDisableDaemonForWorktree(t *testing.T) {
if err := os.Chdir(worktreeDir); err != nil {
t.Fatalf("Failed to change to worktree dir: %v", err)
}
git.ResetCaches()
// Reinitialize config to pick up the new directory's config.yaml
if err := config.Initialize(); err != nil {
@@ -144,6 +147,7 @@ func TestShouldDisableDaemonForWorktree(t *testing.T) {
if err := os.Chdir(worktreeDir); err != nil {
t.Fatalf("Failed to change to worktree dir: %v", err)
}
git.ResetCaches()
// Reinitialize config to pick up the new directory's config.yaml
if err := config.Initialize(); err != nil {
@@ -194,6 +198,7 @@ func TestShouldAutoStartDaemonWorktreeIntegration(t *testing.T) {
if err := os.Chdir(worktreeDir); err != nil {
t.Fatalf("Failed to change to worktree dir: %v", err)
}
git.ResetCaches()
// Clear all daemon-related env vars
os.Unsetenv("BEADS_NO_DAEMON")
@@ -227,6 +232,7 @@ func TestShouldAutoStartDaemonWorktreeIntegration(t *testing.T) {
if err := os.Chdir(worktreeDir); err != nil {
t.Fatalf("Failed to change to worktree dir: %v", err)
}
git.ResetCaches()
// Reinitialize config to pick up the new directory's config.yaml
if err := config.Initialize(); err != nil {
@@ -260,6 +266,7 @@ func TestShouldAutoStartDaemonWorktreeIntegration(t *testing.T) {
if err := os.Chdir(worktreeDir); err != nil {
t.Fatalf("Failed to change to worktree dir: %v", err)
}
git.ResetCaches()
// Reinitialize config to pick up the new directory's config.yaml
if err := config.Initialize(); err != nil {

View File

@@ -793,7 +793,7 @@ func TestMemoryStorage_UpdateIssue_SearchIssues_ReadyWork_BlockedIssues(t *testi
}
// Blocked issues: child is blocked by an open blocker.
blocked, err := store.GetBlockedIssues(ctx)
blocked, err := store.GetBlockedIssues(ctx, types.WorkFilter{})
if err != nil {
t.Fatalf("GetBlockedIssues: %v", err)
}
@@ -810,7 +810,7 @@ func TestMemoryStorage_UpdateIssue_SearchIssues_ReadyWork_BlockedIssues(t *testi
store.mu.Lock()
store.dependencies[missing.ID] = append(store.dependencies[missing.ID], &types.Dependency{IssueID: missing.ID, DependsOnID: "bd-does-not-exist", Type: types.DepBlocks})
store.mu.Unlock()
blocked, err = store.GetBlockedIssues(ctx)
blocked, err = store.GetBlockedIssues(ctx, types.WorkFilter{})
if err != nil {
t.Fatalf("GetBlockedIssues: %v", err)
}

View File

@@ -26,7 +26,7 @@ VERBOSE="${TEST_VERBOSE:-}"
RUN_PATTERN="${TEST_RUN:-}"
COVERAGE="${TEST_COVER:-}"
COVERPROFILE="${TEST_COVERPROFILE:-/tmp/beads.coverage.out}"
COVERPKG="${TEST_COVERPKG:-./...}"
COVERPKG="${TEST_COVERPKG:-}"
# Parse arguments
PACKAGES=()
@@ -81,7 +81,10 @@ if [[ -n "$RUN_PATTERN" ]]; then
fi
if [[ -n "$COVERAGE" ]]; then
CMD+=(-covermode=atomic -coverpkg "$COVERPKG" -coverprofile "$COVERPROFILE")
CMD+=(-covermode=atomic -coverprofile "$COVERPROFILE")
if [[ -n "$COVERPKG" ]]; then
CMD+=(-coverpkg "$COVERPKG")
fi
fi
CMD+=("${PACKAGES[@]}")