Files
beads/cmd/bd/cli_fast_test.go
Steve Yegge 92e6f4c079 refactor: remove legacy autoflush code paths (bd-xsl9)
Remove dual code paths in the autoflush system. FlushManager is now the
only code path for auto-flush operations.

Changes:
- Remove legacy globals: isDirty, needsFullExport, flushTimer
- Remove flushToJSONL() wrapper function (was backward-compat shim)
- Simplify markDirtyAndScheduleFlush/FullExport to just call FlushManager
- Update tests to use FlushManager or flushToJSONLWithState directly

FlushManager handles all flush state internally in its run() goroutine,
eliminating the need for global state. Sandbox mode and tests that do
not need flushing get a no-op when FlushManager is nil.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 01:56:19 -08:00

697 lines
20 KiB
Go

//go:build integration
// +build integration
package main
import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
// Fast CLI tests converted from scripttest suite
// These use in-process testing (calling rootCmd.Execute directly) for speed
// A few tests still use exec.Command for end-to-end validation
//
// Performance improvement (bd-ky74):
// - Before: exec.Command() tests took 2-4 seconds each (~40s total)
// - After: in-process tests take <1 second each, ~10x faster
// - End-to-end test (TestCLI_EndToEnd) still validates binary with exec.Command
var (
inProcessMutex sync.Mutex // Protects concurrent access to rootCmd and global state
)
// setupCLITestDB creates a fresh initialized bd database for CLI tests
func setupCLITestDB(t *testing.T) string {
t.Helper()
tmpDir := createTempDirWithCleanup(t)
runBDInProcess(t, tmpDir, "init", "--prefix", "test", "--quiet")
return tmpDir
}
// createTempDirWithCleanup creates a temp directory with non-fatal cleanup
// This prevents test failures from SQLite file lock cleanup issues
func createTempDirWithCleanup(t *testing.T) string {
t.Helper()
tmpDir, err := os.MkdirTemp("", "bd-cli-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
t.Cleanup(func() {
// Retry cleanup with delays to handle SQLite file locks
// Don't fail the test if cleanup fails - just log it
for i := 0; i < 5; i++ {
err := os.RemoveAll(tmpDir)
if err == nil {
return // Success
}
if i < 4 {
time.Sleep(50 * time.Millisecond)
}
}
// Final attempt failed - log but don't fail test
t.Logf("Warning: Failed to clean up temp dir %s (SQLite file locks)", tmpDir)
})
return tmpDir
}
// runBDInProcess runs bd commands in-process by calling rootCmd.Execute
// This is ~10-20x faster than exec.Command because it avoids process spawn overhead
func runBDInProcess(t *testing.T, dir string, args ...string) string {
t.Helper()
// Serialize all in-process test execution to avoid race conditions
// rootCmd, cobra state, and viper are not thread-safe
inProcessMutex.Lock()
defer inProcessMutex.Unlock()
// Add --no-daemon to all commands except init
if len(args) > 0 && args[0] != "init" {
args = append([]string{"--no-daemon"}, args...)
}
// Save original state
oldStdout := os.Stdout
oldStderr := os.Stderr
oldDir, _ := os.Getwd()
oldArgs := os.Args
// Change to test directory
if err := os.Chdir(dir); err != nil {
t.Fatalf("Failed to chdir to %s: %v", dir, err)
}
// Capture stdout/stderr
rOut, wOut, _ := os.Pipe()
rErr, wErr, _ := os.Pipe()
os.Stdout = wOut
os.Stderr = wErr
// Set args for rootCmd
rootCmd.SetArgs(args)
os.Args = append([]string{"bd"}, args...)
// Set environment
os.Setenv("BEADS_NO_DAEMON", "1")
defer os.Unsetenv("BEADS_NO_DAEMON")
// Execute command
err := rootCmd.Execute()
// Close and clean up all global state to prevent contamination between tests
if store != nil {
store.Close()
store = nil
}
if daemonClient != nil {
daemonClient.Close()
daemonClient = nil
}
// Reset all global flags and state
dbPath = ""
actor = ""
jsonOutput = false
noDaemon = false
noAutoFlush = false
noAutoImport = false
sandboxMode = false
noDb = false
autoFlushEnabled = true
storeActive = false
flushFailureCount = 0
lastFlushError = nil
// Shutdown any existing FlushManager
if flushManager != nil {
_ = flushManager.Shutdown()
flushManager = nil
}
// Reset context state
rootCtx = nil
rootCancel = nil
// Give SQLite time to release file locks before cleanup
time.Sleep(10 * time.Millisecond)
// Close writers and restore
wOut.Close()
wErr.Close()
os.Stdout = oldStdout
os.Stderr = oldStderr
os.Chdir(oldDir)
os.Args = oldArgs
rootCmd.SetArgs(nil)
// Read output (keep stdout and stderr separate)
var outBuf, errBuf bytes.Buffer
outBuf.ReadFrom(rOut)
errBuf.ReadFrom(rErr)
stdout := outBuf.String()
stderr := errBuf.String()
if err != nil {
t.Fatalf("bd %v failed: %v\nStdout: %s\nStderr: %s", args, err, stdout, stderr)
}
// Return only stdout (stderr contains warnings that break JSON parsing)
return stdout
}
func TestCLI_Ready(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
runBDInProcess(t, tmpDir, "create", "Ready issue", "-p", "1")
out := runBDInProcess(t, tmpDir, "ready")
if !strings.Contains(out, "Ready issue") {
t.Errorf("Expected 'Ready issue' in output, got: %s", out)
}
}
func TestCLI_Create(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
out := runBDInProcess(t, tmpDir, "create", "Test issue", "-p", "1", "--json")
// Extract JSON from output (may contain warnings before JSON)
jsonStart := strings.Index(out, "{")
if jsonStart == -1 {
t.Fatalf("No JSON found in output: %s", out)
}
jsonOut := out[jsonStart:]
var result map[string]interface{}
if err := json.Unmarshal([]byte(jsonOut), &result); err != nil {
t.Fatalf("Failed to parse JSON: %v\nOutput: %s", err, jsonOut)
}
if result["title"] != "Test issue" {
t.Errorf("Expected title 'Test issue', got: %v", result["title"])
}
}
func TestCLI_List(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
runBDInProcess(t, tmpDir, "create", "First", "-p", "1")
runBDInProcess(t, tmpDir, "create", "Second", "-p", "2")
out := runBDInProcess(t, tmpDir, "list", "--json")
var issues []map[string]interface{}
if err := json.Unmarshal([]byte(out), &issues); err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
if len(issues) != 2 {
t.Errorf("Expected 2 issues, got %d", len(issues))
}
}
func TestCLI_Update(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
out := runBDInProcess(t, tmpDir, "create", "Issue to update", "-p", "1", "--json")
var issue map[string]interface{}
json.Unmarshal([]byte(out), &issue)
id := issue["id"].(string)
runBDInProcess(t, tmpDir, "update", id, "--status", "in_progress")
out = runBDInProcess(t, tmpDir, "show", id, "--json")
var updated []map[string]interface{}
json.Unmarshal([]byte(out), &updated)
if updated[0]["status"] != "in_progress" {
t.Errorf("Expected status 'in_progress', got: %v", updated[0]["status"])
}
}
func TestCLI_UpdateLabels(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
out := runBDInProcess(t, tmpDir, "create", "Issue for label testing", "-p", "2", "--json")
var issue map[string]interface{}
json.Unmarshal([]byte(out), &issue)
id := issue["id"].(string)
// Test adding labels
runBDInProcess(t, tmpDir, "update", id, "--add-label", "feature", "--add-label", "backend")
out = runBDInProcess(t, tmpDir, "show", id, "--json")
var updated []map[string]interface{}
json.Unmarshal([]byte(out), &updated)
labels := updated[0]["labels"].([]interface{})
if len(labels) != 2 {
t.Errorf("Expected 2 labels after add, got: %d", len(labels))
}
hasBackend, hasFeature := false, false
for _, l := range labels {
if l.(string) == "backend" {
hasBackend = true
}
if l.(string) == "feature" {
hasFeature = true
}
}
if !hasBackend || !hasFeature {
t.Errorf("Expected labels 'backend' and 'feature', got: %v", labels)
}
// Test removing a label
runBDInProcess(t, tmpDir, "update", id, "--remove-label", "backend")
out = runBDInProcess(t, tmpDir, "show", id, "--json")
json.Unmarshal([]byte(out), &updated)
labels = updated[0]["labels"].([]interface{})
if len(labels) != 1 {
t.Errorf("Expected 1 label after remove, got: %d", len(labels))
}
if labels[0].(string) != "feature" {
t.Errorf("Expected label 'feature', got: %v", labels[0])
}
// Test setting labels (replaces all)
runBDInProcess(t, tmpDir, "update", id, "--set-labels", "api,database,critical")
out = runBDInProcess(t, tmpDir, "show", id, "--json")
json.Unmarshal([]byte(out), &updated)
labels = updated[0]["labels"].([]interface{})
if len(labels) != 3 {
t.Errorf("Expected 3 labels after set, got: %d", len(labels))
}
expectedLabels := map[string]bool{"api": true, "database": true, "critical": true}
for _, l := range labels {
if !expectedLabels[l.(string)] {
t.Errorf("Unexpected label: %v", l)
}
}
}
func TestCLI_Close(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
out := runBDInProcess(t, tmpDir, "create", "Issue to close", "-p", "1", "--json")
var issue map[string]interface{}
json.Unmarshal([]byte(out), &issue)
id := issue["id"].(string)
runBDInProcess(t, tmpDir, "close", id, "--reason", "Done")
out = runBDInProcess(t, tmpDir, "show", id, "--json")
var closed []map[string]interface{}
json.Unmarshal([]byte(out), &closed)
if closed[0]["status"] != "closed" {
t.Errorf("Expected status 'closed', got: %v", closed[0]["status"])
}
if closed[0]["close_reason"] != "Done" {
t.Errorf("Expected close_reason 'Done', got: %v", closed[0]["close_reason"])
}
}
func TestCLI_DepAdd(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
out1 := runBDInProcess(t, tmpDir, "create", "First", "-p", "1", "--json")
out2 := runBDInProcess(t, tmpDir, "create", "Second", "-p", "1", "--json")
var issue1, issue2 map[string]interface{}
json.Unmarshal([]byte(out1), &issue1)
json.Unmarshal([]byte(out2), &issue2)
id1 := issue1["id"].(string)
id2 := issue2["id"].(string)
out := runBDInProcess(t, tmpDir, "dep", "add", id2, id1)
if !strings.Contains(out, "Added dependency") {
t.Errorf("Expected 'Added dependency', got: %s", out)
}
}
func TestCLI_DepRemove(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
out1 := runBDInProcess(t, tmpDir, "create", "First", "-p", "1", "--json")
out2 := runBDInProcess(t, tmpDir, "create", "Second", "-p", "1", "--json")
var issue1, issue2 map[string]interface{}
json.Unmarshal([]byte(out1), &issue1)
json.Unmarshal([]byte(out2), &issue2)
id1 := issue1["id"].(string)
id2 := issue2["id"].(string)
runBDInProcess(t, tmpDir, "dep", "add", id2, id1)
out := runBDInProcess(t, tmpDir, "dep", "remove", id2, id1)
if !strings.Contains(out, "Removed dependency") {
t.Errorf("Expected 'Removed dependency', got: %s", out)
}
}
func TestCLI_DepTree(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
out1 := runBDInProcess(t, tmpDir, "create", "Parent", "-p", "1", "--json")
out2 := runBDInProcess(t, tmpDir, "create", "Child", "-p", "1", "--json")
var issue1, issue2 map[string]interface{}
json.Unmarshal([]byte(out1), &issue1)
json.Unmarshal([]byte(out2), &issue2)
id1 := issue1["id"].(string)
id2 := issue2["id"].(string)
runBDInProcess(t, tmpDir, "dep", "add", id2, id1)
out := runBDInProcess(t, tmpDir, "dep", "tree", id1)
if !strings.Contains(out, "Parent") {
t.Errorf("Expected 'Parent' in tree, got: %s", out)
}
}
func TestCLI_Blocked(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
out1 := runBDInProcess(t, tmpDir, "create", "Blocker", "-p", "1", "--json")
out2 := runBDInProcess(t, tmpDir, "create", "Blocked", "-p", "1", "--json")
var issue1, issue2 map[string]interface{}
json.Unmarshal([]byte(out1), &issue1)
json.Unmarshal([]byte(out2), &issue2)
id1 := issue1["id"].(string)
id2 := issue2["id"].(string)
runBDInProcess(t, tmpDir, "dep", "add", id2, id1)
out := runBDInProcess(t, tmpDir, "blocked")
if !strings.Contains(out, "Blocked") {
t.Errorf("Expected 'Blocked' in output, got: %s", out)
}
}
func TestCLI_Stats(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
runBDInProcess(t, tmpDir, "create", "Issue 1", "-p", "1")
runBDInProcess(t, tmpDir, "create", "Issue 2", "-p", "1")
out := runBDInProcess(t, tmpDir, "stats")
if !strings.Contains(out, "Total") || !strings.Contains(out, "2") {
t.Errorf("Expected stats to show 2 issues, got: %s", out)
}
}
func TestCLI_Show(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
out := runBDInProcess(t, tmpDir, "create", "Show test", "-p", "1", "--json")
var issue map[string]interface{}
json.Unmarshal([]byte(out), &issue)
id := issue["id"].(string)
out = runBDInProcess(t, tmpDir, "show", id)
if !strings.Contains(out, "Show test") {
t.Errorf("Expected 'Show test' in output, got: %s", out)
}
}
func TestCLI_Export(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
runBDInProcess(t, tmpDir, "create", "Export test", "-p", "1")
exportFile := filepath.Join(tmpDir, "export.jsonl")
runBDInProcess(t, tmpDir, "export", "-o", exportFile)
if _, err := os.Stat(exportFile); os.IsNotExist(err) {
t.Errorf("Export file not created: %s", exportFile)
}
}
func TestCLI_Import(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
runBDInProcess(t, tmpDir, "create", "Import test", "-p", "1")
exportFile := filepath.Join(tmpDir, "export.jsonl")
runBDInProcess(t, tmpDir, "export", "-o", exportFile)
// Create new db and import
tmpDir2 := createTempDirWithCleanup(t)
runBDInProcess(t, tmpDir2, "init", "--prefix", "test", "--quiet")
runBDInProcess(t, tmpDir2, "import", "-i", exportFile)
out := runBDInProcess(t, tmpDir2, "list", "--json")
var issues []map[string]interface{}
json.Unmarshal([]byte(out), &issues)
if len(issues) != 1 {
t.Errorf("Expected 1 imported issue, got %d", len(issues))
}
}
var testBD string
func init() {
// Use existing bd binary from repo root if available, otherwise build once
bdBinary := "bd"
if runtime.GOOS == "windows" {
bdBinary = "bd.exe"
}
// Check if bd binary exists in repo root (../../bd from cmd/bd/)
repoRoot := filepath.Join("..", "..")
existingBD := filepath.Join(repoRoot, bdBinary)
if _, err := os.Stat(existingBD); err == nil {
// Use existing binary
testBD, _ = filepath.Abs(existingBD)
return
}
// Fall back to building once (for CI or fresh checkouts)
tmpDir, err := os.MkdirTemp("", "bd-cli-test-*")
if err != nil {
panic(err)
}
testBD = filepath.Join(tmpDir, bdBinary)
cmd := exec.Command("go", "build", "-o", testBD, ".")
if out, err := cmd.CombinedOutput(); err != nil {
panic(string(out))
}
}
// runBDExec runs bd via exec.Command for end-to-end testing
// This is kept for a few tests to ensure the actual binary works correctly
func runBDExec(t *testing.T, dir string, args ...string) string {
t.Helper()
// Add --no-daemon to all commands except init
if len(args) > 0 && args[0] != "init" {
args = append([]string{"--no-daemon"}, args...)
}
cmd := exec.Command(testBD, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "BEADS_NO_DAEMON=1")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bd %v failed: %v\nOutput: %s", args, err, out)
}
return string(out)
}
// TestCLI_EndToEnd performs end-to-end testing using the actual binary
// This ensures the compiled binary works correctly when executed normally
func TestCLI_EndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := createTempDirWithCleanup(t)
// Test full workflow with exec.Command to validate binary
runBDExec(t, tmpDir, "init", "--prefix", "test", "--quiet")
out := runBDExec(t, tmpDir, "create", "E2E test", "-p", "1", "--json")
var issue map[string]interface{}
jsonStart := strings.Index(out, "{")
json.Unmarshal([]byte(out[jsonStart:]), &issue)
id := issue["id"].(string)
runBDExec(t, tmpDir, "update", id, "--status", "in_progress")
runBDExec(t, tmpDir, "close", id, "--reason", "Done")
out = runBDExec(t, tmpDir, "show", id, "--json")
var closed []map[string]interface{}
json.Unmarshal([]byte(out), &closed)
if closed[0]["status"] != "closed" {
t.Errorf("Expected status 'closed', got: %v", closed[0]["status"])
}
// Test export
exportFile := filepath.Join(tmpDir, "export.jsonl")
runBDExec(t, tmpDir, "export", "-o", exportFile)
if _, err := os.Stat(exportFile); os.IsNotExist(err) {
t.Errorf("Export file not created: %s", exportFile)
}
}
func TestCLI_Labels(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
out := runBDInProcess(t, tmpDir, "create", "Label test", "-p", "1", "--json")
jsonStart := strings.Index(out, "{")
jsonOut := out[jsonStart:]
var issue map[string]interface{}
json.Unmarshal([]byte(jsonOut), &issue)
id := issue["id"].(string)
// Add label
runBDInProcess(t, tmpDir, "label", "add", id, "urgent")
// List labels
out = runBDInProcess(t, tmpDir, "label", "list", id)
if !strings.Contains(out, "urgent") {
t.Errorf("Expected 'urgent' label, got: %s", out)
}
// Remove label
runBDInProcess(t, tmpDir, "label", "remove", id, "urgent")
out = runBDInProcess(t, tmpDir, "label", "list", id)
if strings.Contains(out, "urgent") {
t.Errorf("Label should be removed, got: %s", out)
}
}
func TestCLI_PriorityFormats(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
// Test numeric priority
out := runBDInProcess(t, tmpDir, "create", "Test P0", "-p", "0", "--json")
jsonStart := strings.Index(out, "{")
jsonOut := out[jsonStart:]
var issue map[string]interface{}
json.Unmarshal([]byte(jsonOut), &issue)
if issue["priority"].(float64) != 0 {
t.Errorf("Expected priority 0, got: %v", issue["priority"])
}
// Test P-format priority
out = runBDInProcess(t, tmpDir, "create", "Test P3", "-p", "P3", "--json")
jsonStart = strings.Index(out, "{")
jsonOut = out[jsonStart:]
json.Unmarshal([]byte(jsonOut), &issue)
if issue["priority"].(float64) != 3 {
t.Errorf("Expected priority 3, got: %v", issue["priority"])
}
// Test update with P-format
id := issue["id"].(string)
runBDInProcess(t, tmpDir, "update", id, "-p", "P1")
out = runBDInProcess(t, tmpDir, "show", id, "--json")
var updated []map[string]interface{}
json.Unmarshal([]byte(out), &updated)
if updated[0]["priority"].(float64) != 1 {
t.Errorf("Expected priority 1 after update, got: %v", updated[0]["priority"])
}
}
func TestCLI_Reopen(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
tmpDir := setupCLITestDB(t)
out := runBDInProcess(t, tmpDir, "create", "Reopen test", "-p", "1", "--json")
jsonStart := strings.Index(out, "{")
jsonOut := out[jsonStart:]
var issue map[string]interface{}
json.Unmarshal([]byte(jsonOut), &issue)
id := issue["id"].(string)
// Close it
runBDInProcess(t, tmpDir, "close", id)
// Reopen it
runBDInProcess(t, tmpDir, "reopen", id)
out = runBDInProcess(t, tmpDir, "show", id, "--json")
var reopened []map[string]interface{}
json.Unmarshal([]byte(out), &reopened)
if reopened[0]["status"] != "open" {
t.Errorf("Expected status 'open', got: %v", reopened[0]["status"])
}
}