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:
107
cmd/bd/import_helpers_test.go
Normal file
107
cmd/bd/import_helpers_test.go
Normal 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
116
cmd/bd/list_helpers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
71
cmd/bd/sync_helpers_more_test.go
Normal file
71
cmd/bd/sync_helpers_more_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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[@]}")
|
||||
|
||||
Reference in New Issue
Block a user