package main import ( "bytes" "context" "io" "os" "os/exec" "path/filepath" "runtime" "testing" "time" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/config" ) func tempSockDir(t *testing.T) string { t.Helper() base := "/tmp" if runtime.GOOS == windowsOS { base = os.TempDir() } else if _, err := os.Stat(base); err != nil { base = os.TempDir() } d, err := os.MkdirTemp(base, "bd-sock-*") if err != nil { t.Fatalf("MkdirTemp: %v", err) } t.Cleanup(func() { _ = os.RemoveAll(d) }) return d } func startTestRPCServer(t *testing.T) (socketPath string, cleanup func()) { t.Helper() tmpDir := tempSockDir(t) beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0o750); err != nil { t.Fatalf("MkdirAll: %v", err) } socketPath = filepath.Join(beadsDir, "bd.sock") db := filepath.Join(beadsDir, "test.db") store := newTestStore(t, db) ctx, cancel := context.WithCancel(context.Background()) log := newTestLogger() server, _, err := startRPCServer(ctx, socketPath, store, tmpDir, db, log) if err != nil { cancel() t.Fatalf("startRPCServer: %v", err) } cleanup = func() { cancel() if server != nil { _ = server.Stop() } } return socketPath, cleanup } func captureStderr(t *testing.T, fn func()) string { t.Helper() old := os.Stderr r, w, err := os.Pipe() if err != nil { t.Fatalf("os.Pipe: %v", err) } os.Stderr = w var buf bytes.Buffer done := make(chan struct{}) go func() { _, _ = io.Copy(&buf, r) close(done) }() fn() _ = w.Close() os.Stderr = old <-done _ = r.Close() return buf.String() } func TestDaemonAutostart_AcquireStartLock_CreatesAndCleansStale(t *testing.T) { tmpDir := t.TempDir() lockPath := filepath.Join(tmpDir, "bd.sock.startlock") pid, err := readPIDFromFile(lockPath) if err == nil || pid != 0 { // lock doesn't exist yet; expect read to fail. } if !acquireStartLock(lockPath, filepath.Join(tmpDir, "bd.sock")) { t.Fatalf("expected acquireStartLock to succeed") } got, err := readPIDFromFile(lockPath) if err != nil { t.Fatalf("readPIDFromFile: %v", err) } if got != os.Getpid() { t.Fatalf("expected lock PID %d, got %d", os.Getpid(), got) } // Stale lock: dead/unreadable PID should be removed and recreated. if err := os.WriteFile(lockPath, []byte("0\n"), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } if !acquireStartLock(lockPath, filepath.Join(tmpDir, "bd.sock")) { t.Fatalf("expected acquireStartLock to succeed on stale lock") } got, err = readPIDFromFile(lockPath) if err != nil { t.Fatalf("readPIDFromFile: %v", err) } if got != os.Getpid() { t.Fatalf("expected recreated lock PID %d, got %d", os.Getpid(), got) } } func TestDaemonAutostart_SocketHealthAndReadiness(t *testing.T) { socketPath, cleanup := startTestRPCServer(t) defer cleanup() if !canDialSocket(socketPath, 500*time.Millisecond) { t.Fatalf("expected canDialSocket to succeed") } if !isDaemonHealthy(socketPath) { t.Fatalf("expected isDaemonHealthy to succeed") } if !waitForSocketReadiness(socketPath, 500*time.Millisecond) { t.Fatalf("expected waitForSocketReadiness to succeed") } missing := filepath.Join(tempSockDir(t), "missing.sock") if canDialSocket(missing, 50*time.Millisecond) { t.Fatalf("expected canDialSocket to fail") } if waitForSocketReadiness(missing, 200*time.Millisecond) { t.Fatalf("expected waitForSocketReadiness to time out") } } func TestDaemonAutostart_HandleExistingSocket(t *testing.T) { socketPath, cleanup := startTestRPCServer(t) defer cleanup() if !handleExistingSocket(socketPath) { t.Fatalf("expected handleExistingSocket true for running daemon") } } func TestDaemonAutostart_HandleExistingSocket_StaleCleansUp(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0o750); err != nil { t.Fatalf("MkdirAll: %v", err) } socketPath := filepath.Join(beadsDir, "bd.sock") pidFile := filepath.Join(beadsDir, "daemon.pid") if err := os.WriteFile(socketPath, []byte("not-a-socket"), 0o600); err != nil { t.Fatalf("WriteFile socket: %v", err) } if err := os.WriteFile(pidFile, []byte("0\n"), 0o600); err != nil { t.Fatalf("WriteFile pid: %v", err) } if handleExistingSocket(socketPath) { t.Fatalf("expected false for stale socket") } if _, err := os.Stat(socketPath); !os.IsNotExist(err) { t.Fatalf("expected socket removed") } if _, err := os.Stat(pidFile); !os.IsNotExist(err) { t.Fatalf("expected pidfile removed") } } func TestDaemonAutostart_TryAutoStartDaemon_EarlyExits(t *testing.T) { oldFailures := daemonStartFailures oldLast := lastDaemonStartAttempt defer func() { daemonStartFailures = oldFailures lastDaemonStartAttempt = oldLast }() daemonStartFailures = 1 lastDaemonStartAttempt = time.Now() if tryAutoStartDaemon(filepath.Join(t.TempDir(), "bd.sock")) { t.Fatalf("expected tryAutoStartDaemon to skip due to backoff") } daemonStartFailures = 0 lastDaemonStartAttempt = time.Time{} socketPath, cleanup := startTestRPCServer(t) defer cleanup() if !tryAutoStartDaemon(socketPath) { t.Fatalf("expected tryAutoStartDaemon true when daemon already healthy") } } func TestDaemonAutostart_MiscHelpers(t *testing.T) { if determineSocketPath("/x") != "/x" { t.Fatalf("determineSocketPath should be identity") } if err := config.Initialize(); err != nil { t.Fatalf("config.Initialize: %v", err) } old := config.GetDuration("flush-debounce") defer config.Set("flush-debounce", old) config.Set("flush-debounce", 0) if got := getDebounceDuration(); got != 5*time.Second { t.Fatalf("expected default debounce 5s, got %v", got) } config.Set("flush-debounce", 2*time.Second) if got := getDebounceDuration(); got != 2*time.Second { t.Fatalf("expected debounce 2s, got %v", got) } } func TestDaemonAutostart_EmitVerboseWarning(t *testing.T) { old := daemonStatus defer func() { daemonStatus = old }() daemonStatus.SocketPath = "/tmp/bd.sock" for _, tt := range []struct { reason string shouldWrite bool }{ {FallbackConnectFailed, true}, {FallbackHealthFailed, true}, {FallbackAutoStartDisabled, true}, {FallbackAutoStartFailed, true}, {FallbackDaemonUnsupported, true}, {FallbackWorktreeSafety, false}, {FallbackFlagNoDaemon, false}, } { t.Run(tt.reason, func(t *testing.T) { daemonStatus.FallbackReason = tt.reason out := captureStderr(t, emitVerboseWarning) if tt.shouldWrite && out == "" { t.Fatalf("expected output") } if !tt.shouldWrite && out != "" { t.Fatalf("expected no output, got %q", out) } }) } } func TestDaemonAutostart_StartDaemonProcess_Stubbed(t *testing.T) { oldExec := execCommandFn oldWait := waitForSocketReadinessFn oldCfg := configureDaemonProcessFn defer func() { execCommandFn = oldExec waitForSocketReadinessFn = oldWait configureDaemonProcessFn = oldCfg }() execCommandFn = func(string, ...string) *exec.Cmd { return exec.Command(os.Args[0], "-test.run=^$") } waitForSocketReadinessFn = func(string, time.Duration) bool { return true } configureDaemonProcessFn = func(*exec.Cmd) {} if !startDaemonProcess(filepath.Join(t.TempDir(), "bd.sock")) { t.Fatalf("expected startDaemonProcess true when readiness stubbed") } } func TestDaemonAutostart_RestartDaemonForVersionMismatch_Stubbed(t *testing.T) { oldExec := execCommandFn oldWait := waitForSocketReadinessFn oldRun := isDaemonRunningFn oldCfg := configureDaemonProcessFn defer func() { execCommandFn = oldExec waitForSocketReadinessFn = oldWait isDaemonRunningFn = oldRun configureDaemonProcessFn = oldCfg }() tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0o750); err != nil { t.Fatalf("MkdirAll: %v", err) } oldDB := dbPath defer func() { dbPath = oldDB }() dbPath = filepath.Join(beadsDir, "test.db") pidFile, err := getPIDFilePath() if err != nil { t.Fatalf("getPIDFilePath: %v", err) } sock := getSocketPath() if err := os.WriteFile(pidFile, []byte("999999\n"), 0o600); err != nil { t.Fatalf("WriteFile pid: %v", err) } if err := os.WriteFile(sock, []byte("stale"), 0o600); err != nil { t.Fatalf("WriteFile sock: %v", err) } execCommandFn = func(string, ...string) *exec.Cmd { return exec.Command(os.Args[0], "-test.run=^$") } waitForSocketReadinessFn = func(string, time.Duration) bool { return true } isDaemonRunningFn = func(string) (bool, int) { return false, 0 } configureDaemonProcessFn = func(*exec.Cmd) {} if !restartDaemonForVersionMismatch() { t.Fatalf("expected restartDaemonForVersionMismatch true when stubbed") } if _, err := os.Stat(pidFile); !os.IsNotExist(err) { t.Fatalf("expected pidfile removed") } if _, err := os.Stat(sock); !os.IsNotExist(err) { t.Fatalf("expected socket removed") } } // TestIsWispOperation tests the wisp operation detection for auto-daemon-bypass (bd-ta4r) func TestIsWispOperation(t *testing.T) { // Helper to create a command with parent hierarchy makeCmd := func(names ...string) *cobra.Command { var current *cobra.Command for i, name := range names { cmd := &cobra.Command{Use: name} if i == 0 { current = cmd } else { current.AddCommand(cmd) current = cmd } } return current } tests := []struct { name string cmdNames []string // hierarchy: root, child, grandchild... args []string want bool }{ // Wisp subcommands { name: "mol wisp (direct)", cmdNames: []string{"bd", "mol", "wisp"}, args: []string{}, want: true, }, { name: "mol wisp create", cmdNames: []string{"bd", "mol", "wisp", "create"}, args: []string{"some-proto"}, want: true, }, { name: "mol wisp list", cmdNames: []string{"bd", "mol", "wisp", "list"}, args: []string{}, want: true, }, { name: "mol wisp gc", cmdNames: []string{"bd", "mol", "wisp", "gc"}, args: []string{}, want: true, }, // mol burn and squash (wisp-only operations) { name: "mol burn", cmdNames: []string{"bd", "mol", "burn"}, args: []string{"bd-eph-abc"}, want: true, }, { name: "mol squash", cmdNames: []string{"bd", "mol", "squash"}, args: []string{"bd-eph-abc"}, want: true, }, // Ephemeral issue IDs in args { name: "close with bd-eph ID", cmdNames: []string{"bd", "close"}, args: []string{"bd-eph-abc123"}, want: true, }, { name: "show with gt-eph ID", cmdNames: []string{"bd", "show"}, args: []string{"gt-eph-xyz"}, want: true, }, { name: "update with eph- prefix", cmdNames: []string{"bd", "update"}, args: []string{"eph-test", "--status=closed"}, want: true, }, // Non-wisp operations (should NOT bypass) { name: "regular show", cmdNames: []string{"bd", "show"}, args: []string{"bd-abc123"}, want: false, }, { name: "regular close", cmdNames: []string{"bd", "close"}, args: []string{"bd-xyz"}, want: false, }, { name: "mol pour (persistent)", cmdNames: []string{"bd", "mol", "pour"}, args: []string{"some-formula"}, want: false, }, { name: "list command", cmdNames: []string{"bd", "list"}, args: []string{}, want: false, }, // Edge cases { name: "flag that looks like eph ID should be ignored", cmdNames: []string{"bd", "show"}, args: []string{"--format=bd-eph-style", "bd-regular"}, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := makeCmd(tt.cmdNames...) got := isWispOperation(cmd, tt.args) if got != tt.want { t.Errorf("isWispOperation() = %v, want %v", got, tt.want) } }) } }