* feat(daemon): unify auto-sync config for simpler agent workflows ## Problem Agents running `bd sync` at session end caused delays in the Claude Code "event loop", slowing development. The daemon was already auto-exporting DB→JSONL instantly, but auto-commit and auto-push weren't enabled by default when sync-branch was configured - requiring manual `bd sync`. Additionally, having three separate config options (auto-commit, auto-push, auto-pull) was confusing and could get out of sync. ## Solution Simplify to two intuitive sync modes: 1. **Read/Write Mode** (`daemon.auto-sync: true` or `BEADS_AUTO_SYNC=true`) - Enables auto-commit + auto-push + auto-pull - Full bidirectional sync - eliminates need for manual `bd sync` - Default when sync-branch is configured 2. **Read-Only Mode** (`daemon.auto-pull: true` or `BEADS_AUTO_PULL=true`) - Only receives updates from team - Does NOT auto-publish changes - Useful for experimental work or manual review before sharing ## Benefits - **Faster agent workflows**: No more `bd sync` delays at session end - **Simpler config**: Two modes instead of three separate toggles - **Backward compatible**: Legacy auto_commit/auto_push settings still work (treated as auto-sync=true) - **Adaptive `bd prime`**: Session close protocol adapts when daemon is auto-syncing (shows simplified 4-step git workflow, no `bd sync`) - **Doctor warnings**: `bd doctor` warns about deprecated legacy config ## Changes - cmd/bd/daemon.go: Add loadDaemonAutoSettings() with unified config logic - cmd/bd/doctor.go: Add CheckLegacyDaemonConfig call - cmd/bd/doctor/daemon.go: Add CheckDaemonAutoSync, CheckLegacyDaemonConfig - cmd/bd/init_team.go: Use daemon.auto-sync in team wizard - cmd/bd/prime.go: Detect daemon auto-sync, adapt session close protocol - cmd/bd/prime_test.go: Add stubIsDaemonAutoSyncing for testing * docs: add comprehensive daemon technical analysis Add daemon-summary.md documenting the beads daemon architecture, memory analysis (explaining the 30-35MB footprint), platform support comparison, historical problems and fixes, and architectural guidance for other projects implementing similar daemon patterns. Key sections: - Architecture deep dive with component diagrams - Memory breakdown (SQLite WASM runtime is the main contributor) - Platform support matrix (macOS/Linux full, Windows partial) - Historical bugs and their fixes with reusable patterns - Analysis of daemon usefulness without database (verdict: low value) - Expert-reviewed improvement proposals (3 recommended, 3 skipped) - Technical design patterns for other implementations * feat: add cross-platform CI matrix and dual-mode test framework Cross-Platform CI: - Add Windows, macOS, Linux matrix to catch platform-specific bugs - Linux: full tests with race detector and coverage - macOS: full tests with race detector - Windows: full tests without race detector (performance) - Catches bugs like GH#880 (macOS path casing) and GH#387 (Windows daemon) Dual-Mode Test Framework (cmd/bd/dual_mode_test.go): - Runs tests in both direct mode and daemon mode - Prevents recurring bug pattern (GH#719, GH#751, bd-fu83) - Provides DualModeTestEnv with helper methods for common operations - Includes 5 example tests demonstrating the pattern Documentation: - Add dual-mode testing section to CONTRIBUTING.md - Document RunDualModeTest API and available helpers Test Fixes: - Fix sync_local_only_test.go gitPull/gitPush calls - Add gate_no_daemon_test.go for beads-70c4 investigation * fix(test): isolate TestFindBeadsDir tests with BEADS_DIR env var The tests were finding the real project's .beads directory instead of the temp directory because FindBeadsDir() walks up the directory tree. Using BEADS_DIR env var provides proper test isolation. * fix(test): stop daemon before running test suite guard The test suite guard checks that tests don't modify the real repo's .beads directory. However, a background daemon running auto-sync would touch issues.jsonl during test execution, causing false positives. Changes: - Set BEADS_NO_DAEMON=1 to prevent daemon auto-start from tests - Stop any running daemon for the repo before taking the "before" snapshot - Uses exec to call `bd daemon --stop` to avoid import cycle issues * chore: revert .beads/issues.jsonl to upstream/main Per CONTRIBUTING.md, .beads/issues.jsonl should not be modified in PRs.
793 lines
20 KiB
Go
793 lines
20 KiB
Go
// dual_mode_test.go - Test framework for ensuring commands work in both daemon and direct modes.
|
|
//
|
|
// PROBLEM:
|
|
// Multiple bugs have occurred where commands work in one mode but not the other:
|
|
// - GH#751: bd graph accessed nil store in daemon mode
|
|
// - GH#719: bd create -f bypassed daemon RPC
|
|
// - bd-fu83: relate/duplicate used direct store when daemon was running
|
|
//
|
|
// SOLUTION:
|
|
// This file provides a reusable test pattern that runs the same test logic
|
|
// in both direct mode (--no-daemon) and daemon mode, ensuring commands
|
|
// behave identically regardless of which mode they're running in.
|
|
//
|
|
// USAGE:
|
|
//
|
|
// func TestCreateCommand(t *testing.T) {
|
|
// RunDualModeTest(t, "create basic issue", func(t *testing.T, env *DualModeTestEnv) {
|
|
// // Create an issue - this code runs twice: once in direct mode, once with daemon
|
|
// issue := &types.Issue{
|
|
// Title: "Test issue",
|
|
// IssueType: types.TypeTask,
|
|
// Status: types.StatusOpen,
|
|
// Priority: 2,
|
|
// }
|
|
// err := env.CreateIssue(issue)
|
|
// if err != nil {
|
|
// t.Fatalf("CreateIssue failed: %v", err)
|
|
// }
|
|
//
|
|
// // Verify issue was created
|
|
// got, err := env.GetIssue(issue.ID)
|
|
// if err != nil {
|
|
// t.Fatalf("GetIssue failed: %v", err)
|
|
// }
|
|
// if got.Title != "Test issue" {
|
|
// t.Errorf("expected title 'Test issue', got %q", got.Title)
|
|
// }
|
|
// })
|
|
// }
|
|
//
|
|
// The test framework handles:
|
|
// - Setting up isolated test environments (temp dirs, databases)
|
|
// - Starting/stopping daemon for daemon mode tests
|
|
// - Saving/restoring global state between runs
|
|
// - Providing a unified API for common operations
|
|
|
|
//go:build integration
|
|
// +build integration
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestMode indicates which mode the test is running in
|
|
type TestMode string
|
|
|
|
const (
|
|
// DirectMode: Commands access SQLite directly (--no-daemon)
|
|
DirectMode TestMode = "direct"
|
|
// DaemonMode: Commands communicate via RPC to a background daemon
|
|
DaemonMode TestMode = "daemon"
|
|
)
|
|
|
|
// DualModeTestEnv provides a unified test environment that works in both modes.
|
|
// Tests should use this interface rather than accessing global state directly.
|
|
type DualModeTestEnv struct {
|
|
t *testing.T
|
|
mode TestMode
|
|
tmpDir string
|
|
beadsDir string
|
|
dbPath string
|
|
socketPath string
|
|
|
|
// Direct mode resources
|
|
store *sqlite.SQLiteStorage
|
|
|
|
// Daemon mode resources
|
|
client *rpc.Client
|
|
server *rpc.Server
|
|
serverDone chan error
|
|
|
|
// Context for operations
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// Mode returns the current test mode (direct or daemon)
|
|
func (e *DualModeTestEnv) Mode() TestMode {
|
|
return e.mode
|
|
}
|
|
|
|
// Context returns the test context
|
|
func (e *DualModeTestEnv) Context() context.Context {
|
|
return e.ctx
|
|
}
|
|
|
|
// Store returns the direct store (only valid in DirectMode)
|
|
// For mode-agnostic operations, use the helper methods instead.
|
|
func (e *DualModeTestEnv) Store() *sqlite.SQLiteStorage {
|
|
if e.mode != DirectMode {
|
|
e.t.Fatal("Store() called in daemon mode - use helper methods instead")
|
|
}
|
|
return e.store
|
|
}
|
|
|
|
// Client returns the RPC client (only valid in DaemonMode)
|
|
// For mode-agnostic operations, use the helper methods instead.
|
|
func (e *DualModeTestEnv) Client() *rpc.Client {
|
|
if e.mode != DaemonMode {
|
|
e.t.Fatal("Client() called in direct mode - use helper methods instead")
|
|
}
|
|
return e.client
|
|
}
|
|
|
|
// CreateIssue creates an issue in either mode
|
|
func (e *DualModeTestEnv) CreateIssue(issue *types.Issue) error {
|
|
if e.mode == DirectMode {
|
|
return e.store.CreateIssue(e.ctx, issue, "test")
|
|
}
|
|
|
|
// Daemon mode: use RPC
|
|
args := &rpc.CreateArgs{
|
|
Title: issue.Title,
|
|
Description: issue.Description,
|
|
IssueType: string(issue.IssueType),
|
|
Priority: issue.Priority,
|
|
}
|
|
resp, err := e.client.Create(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !resp.Success {
|
|
return fmt.Errorf("create failed: %s", resp.Error)
|
|
}
|
|
|
|
// Parse response to get the created issue ID
|
|
// The RPC response contains the created issue as JSON
|
|
var createdIssue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &createdIssue); err != nil {
|
|
return fmt.Errorf("failed to parse created issue: %w", err)
|
|
}
|
|
issue.ID = createdIssue.ID
|
|
return nil
|
|
}
|
|
|
|
// GetIssue retrieves an issue by ID in either mode
|
|
func (e *DualModeTestEnv) GetIssue(id string) (*types.Issue, error) {
|
|
if e.mode == DirectMode {
|
|
return e.store.GetIssue(e.ctx, id)
|
|
}
|
|
|
|
// Daemon mode: use RPC
|
|
args := &rpc.ShowArgs{ID: id}
|
|
resp, err := e.client.Show(args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !resp.Success {
|
|
return nil, fmt.Errorf("show failed: %s", resp.Error)
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
|
return nil, fmt.Errorf("failed to parse issue: %w", err)
|
|
}
|
|
return &issue, nil
|
|
}
|
|
|
|
// UpdateIssue updates an issue in either mode
|
|
func (e *DualModeTestEnv) UpdateIssue(id string, updates map[string]interface{}) error {
|
|
if e.mode == DirectMode {
|
|
return e.store.UpdateIssue(e.ctx, id, updates, "test")
|
|
}
|
|
|
|
// Daemon mode: use RPC - convert map to UpdateArgs fields
|
|
args := &rpc.UpdateArgs{ID: id}
|
|
|
|
// Map common fields to their RPC counterparts
|
|
if title, ok := updates["title"].(string); ok {
|
|
args.Title = &title
|
|
}
|
|
if status, ok := updates["status"].(types.Status); ok {
|
|
s := string(status)
|
|
args.Status = &s
|
|
}
|
|
if statusStr, ok := updates["status"].(string); ok {
|
|
args.Status = &statusStr
|
|
}
|
|
if priority, ok := updates["priority"].(int); ok {
|
|
args.Priority = &priority
|
|
}
|
|
if desc, ok := updates["description"].(string); ok {
|
|
args.Description = &desc
|
|
}
|
|
|
|
resp, err := e.client.Update(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !resp.Success {
|
|
return fmt.Errorf("update failed: %s", resp.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteIssue marks an issue as deleted (tombstoned) in either mode
|
|
func (e *DualModeTestEnv) DeleteIssue(id string, force bool) error {
|
|
if e.mode == DirectMode {
|
|
updates := map[string]interface{}{
|
|
"status": types.StatusTombstone,
|
|
}
|
|
return e.store.UpdateIssue(e.ctx, id, updates, "test")
|
|
}
|
|
|
|
// Daemon mode: use RPC
|
|
args := &rpc.DeleteArgs{
|
|
IDs: []string{id},
|
|
Force: force,
|
|
DryRun: false,
|
|
Reason: "test deletion",
|
|
}
|
|
resp, err := e.client.Delete(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !resp.Success {
|
|
return fmt.Errorf("delete failed: %s", resp.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddDependency adds a dependency in either mode
|
|
func (e *DualModeTestEnv) AddDependency(issueID, dependsOnID string, depType types.DependencyType) error {
|
|
if e.mode == DirectMode {
|
|
dep := &types.Dependency{
|
|
IssueID: issueID,
|
|
DependsOnID: dependsOnID,
|
|
Type: depType,
|
|
}
|
|
return e.store.AddDependency(e.ctx, dep, "test")
|
|
}
|
|
|
|
// Daemon mode: use RPC
|
|
args := &rpc.DepAddArgs{
|
|
FromID: issueID,
|
|
ToID: dependsOnID,
|
|
DepType: string(depType),
|
|
}
|
|
resp, err := e.client.AddDependency(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !resp.Success {
|
|
return fmt.Errorf("add dependency failed: %s", resp.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListIssues returns issues matching the filter in either mode
|
|
func (e *DualModeTestEnv) ListIssues(filter types.IssueFilter) ([]*types.Issue, error) {
|
|
if e.mode == DirectMode {
|
|
return e.store.SearchIssues(e.ctx, "", filter)
|
|
}
|
|
|
|
// Daemon mode: use RPC - convert filter to ListArgs
|
|
args := &rpc.ListArgs{}
|
|
if filter.Status != nil {
|
|
args.Status = string(*filter.Status)
|
|
}
|
|
if filter.Priority != nil {
|
|
args.Priority = filter.Priority
|
|
}
|
|
if filter.IssueType != nil {
|
|
args.IssueType = string(*filter.IssueType)
|
|
}
|
|
|
|
resp, err := e.client.List(args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !resp.Success {
|
|
return nil, fmt.Errorf("list failed: %s", resp.Error)
|
|
}
|
|
|
|
var issues []*types.Issue
|
|
if err := json.Unmarshal(resp.Data, &issues); err != nil {
|
|
return nil, fmt.Errorf("failed to parse issues: %w", err)
|
|
}
|
|
return issues, nil
|
|
}
|
|
|
|
// GetReadyWork returns issues ready for work in either mode
|
|
func (e *DualModeTestEnv) GetReadyWork() ([]*types.Issue, error) {
|
|
if e.mode == DirectMode {
|
|
return e.store.GetReadyWork(e.ctx, types.WorkFilter{})
|
|
}
|
|
|
|
// Daemon mode: use RPC
|
|
args := &rpc.ReadyArgs{}
|
|
resp, err := e.client.Ready(args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !resp.Success {
|
|
return nil, fmt.Errorf("ready failed: %s", resp.Error)
|
|
}
|
|
|
|
var issues []*types.Issue
|
|
if err := json.Unmarshal(resp.Data, &issues); err != nil {
|
|
return nil, fmt.Errorf("failed to parse issues: %w", err)
|
|
}
|
|
return issues, nil
|
|
}
|
|
|
|
// AddLabel adds a label to an issue in either mode
|
|
func (e *DualModeTestEnv) AddLabel(issueID, label string) error {
|
|
if e.mode == DirectMode {
|
|
return e.store.AddLabel(e.ctx, issueID, label, "test")
|
|
}
|
|
|
|
// Daemon mode: use RPC
|
|
args := &rpc.LabelAddArgs{
|
|
ID: issueID,
|
|
Label: label,
|
|
}
|
|
resp, err := e.client.AddLabel(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !resp.Success {
|
|
return fmt.Errorf("add label failed: %s", resp.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveLabel removes a label from an issue in either mode
|
|
func (e *DualModeTestEnv) RemoveLabel(issueID, label string) error {
|
|
if e.mode == DirectMode {
|
|
return e.store.RemoveLabel(e.ctx, issueID, label, "test")
|
|
}
|
|
|
|
// Daemon mode: use RPC
|
|
args := &rpc.LabelRemoveArgs{
|
|
ID: issueID,
|
|
Label: label,
|
|
}
|
|
resp, err := e.client.RemoveLabel(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !resp.Success {
|
|
return fmt.Errorf("remove label failed: %s", resp.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddComment adds a comment to an issue in either mode
|
|
func (e *DualModeTestEnv) AddComment(issueID, text string) error {
|
|
if e.mode == DirectMode {
|
|
return e.store.AddComment(e.ctx, issueID, "test", text)
|
|
}
|
|
|
|
// Daemon mode: use RPC
|
|
args := &rpc.CommentAddArgs{
|
|
ID: issueID,
|
|
Author: "test",
|
|
Text: text,
|
|
}
|
|
resp, err := e.client.AddComment(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !resp.Success {
|
|
return fmt.Errorf("add comment failed: %s", resp.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CloseIssue closes an issue with a reason in either mode
|
|
func (e *DualModeTestEnv) CloseIssue(id, reason string) error {
|
|
if e.mode == DirectMode {
|
|
updates := map[string]interface{}{
|
|
"status": types.StatusClosed,
|
|
"close_reason": reason,
|
|
}
|
|
return e.store.UpdateIssue(e.ctx, id, updates, "test")
|
|
}
|
|
|
|
// Daemon mode: use RPC
|
|
args := &rpc.CloseArgs{
|
|
ID: id,
|
|
Reason: reason,
|
|
}
|
|
resp, err := e.client.CloseIssue(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !resp.Success {
|
|
return fmt.Errorf("close failed: %s", resp.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TmpDir returns the test temporary directory
|
|
func (e *DualModeTestEnv) TmpDir() string {
|
|
return e.tmpDir
|
|
}
|
|
|
|
// BeadsDir returns the .beads directory path
|
|
func (e *DualModeTestEnv) BeadsDir() string {
|
|
return e.beadsDir
|
|
}
|
|
|
|
// DBPath returns the database file path
|
|
func (e *DualModeTestEnv) DBPath() string {
|
|
return e.dbPath
|
|
}
|
|
|
|
// DualModeTestFunc is the function signature for tests that run in both modes
|
|
type DualModeTestFunc func(t *testing.T, env *DualModeTestEnv)
|
|
|
|
// RunDualModeTest runs a test function in both direct mode and daemon mode.
|
|
// This ensures the tested behavior works correctly regardless of which mode
|
|
// the CLI is operating in.
|
|
func RunDualModeTest(t *testing.T, name string, testFn DualModeTestFunc) {
|
|
t.Helper()
|
|
|
|
// Run in direct mode
|
|
t.Run(name+"_direct", func(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping dual-mode test in short mode")
|
|
}
|
|
env := setupDirectModeEnv(t)
|
|
testFn(t, env)
|
|
})
|
|
|
|
// Run in daemon mode
|
|
t.Run(name+"_daemon", func(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping dual-mode test in short mode")
|
|
}
|
|
env := setupDaemonModeEnv(t)
|
|
testFn(t, env)
|
|
})
|
|
}
|
|
|
|
// RunDirectModeOnly runs a test only in direct mode.
|
|
// Use sparingly - prefer RunDualModeTest for most tests.
|
|
func RunDirectModeOnly(t *testing.T, name string, testFn DualModeTestFunc) {
|
|
t.Helper()
|
|
t.Run(name, func(t *testing.T) {
|
|
env := setupDirectModeEnv(t)
|
|
testFn(t, env)
|
|
})
|
|
}
|
|
|
|
// RunDaemonModeOnly runs a test only in daemon mode.
|
|
// Use sparingly - prefer RunDualModeTest for most tests.
|
|
func RunDaemonModeOnly(t *testing.T, name string, testFn DualModeTestFunc) {
|
|
t.Helper()
|
|
t.Run(name, func(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping daemon test in short mode")
|
|
}
|
|
env := setupDaemonModeEnv(t)
|
|
testFn(t, env)
|
|
})
|
|
}
|
|
|
|
// setupDirectModeEnv creates a test environment for direct mode testing
|
|
func setupDirectModeEnv(t *testing.T) *DualModeTestEnv {
|
|
t.Helper()
|
|
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("failed to create .beads dir: %v", err)
|
|
}
|
|
|
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
|
store := newTestStore(t, dbPath)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
t.Cleanup(cancel)
|
|
|
|
env := &DualModeTestEnv{
|
|
t: t,
|
|
mode: DirectMode,
|
|
tmpDir: tmpDir,
|
|
beadsDir: beadsDir,
|
|
dbPath: dbPath,
|
|
store: store,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
|
|
return env
|
|
}
|
|
|
|
// setupDaemonModeEnv creates a test environment with a running daemon
|
|
func setupDaemonModeEnv(t *testing.T) *DualModeTestEnv {
|
|
t.Helper()
|
|
|
|
tmpDir := makeSocketTempDir(t)
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("failed to create .beads dir: %v", err)
|
|
}
|
|
|
|
// Initialize git repo (required for daemon)
|
|
initTestGitRepo(t, tmpDir)
|
|
|
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
|
socketPath := filepath.Join(beadsDir, "bd.sock")
|
|
store := newTestStore(t, dbPath)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
|
|
// Create daemon logger
|
|
log := daemonLogger{logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelInfo}))}
|
|
|
|
// Start RPC server
|
|
server, serverErrChan, err := startRPCServer(ctx, socketPath, store, tmpDir, dbPath, log)
|
|
if err != nil {
|
|
cancel()
|
|
t.Fatalf("failed to start RPC server: %v", err)
|
|
}
|
|
|
|
// Wait for server to be ready
|
|
select {
|
|
case <-server.WaitReady():
|
|
// Server is ready
|
|
case <-time.After(5 * time.Second):
|
|
cancel()
|
|
t.Fatal("server did not become ready within 5 seconds")
|
|
}
|
|
|
|
// Connect RPC client
|
|
client, err := rpc.TryConnect(socketPath)
|
|
if err != nil || client == nil {
|
|
cancel()
|
|
server.Stop()
|
|
t.Fatalf("failed to connect RPC client: %v", err)
|
|
}
|
|
|
|
// Consume server errors in background
|
|
serverDone := make(chan error, 1)
|
|
go func() {
|
|
select {
|
|
case err := <-serverErrChan:
|
|
serverDone <- err
|
|
case <-ctx.Done():
|
|
serverDone <- ctx.Err()
|
|
}
|
|
}()
|
|
|
|
env := &DualModeTestEnv{
|
|
t: t,
|
|
mode: DaemonMode,
|
|
tmpDir: tmpDir,
|
|
beadsDir: beadsDir,
|
|
dbPath: dbPath,
|
|
socketPath: socketPath,
|
|
store: store,
|
|
client: client,
|
|
server: server,
|
|
serverDone: serverDone,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
|
|
// Register cleanup
|
|
t.Cleanup(func() {
|
|
if client != nil {
|
|
client.Close()
|
|
}
|
|
if server != nil {
|
|
server.Stop()
|
|
}
|
|
cancel()
|
|
os.RemoveAll(tmpDir)
|
|
})
|
|
|
|
return env
|
|
}
|
|
|
|
// ============================================================================
|
|
// Example dual-mode tests demonstrating the pattern
|
|
// ============================================================================
|
|
|
|
// TestDualMode_CreateAndRetrieveIssue demonstrates the basic dual-mode test pattern
|
|
func TestDualMode_CreateAndRetrieveIssue(t *testing.T) {
|
|
RunDualModeTest(t, "create_and_retrieve", func(t *testing.T, env *DualModeTestEnv) {
|
|
// This code runs twice: once in direct mode, once with daemon
|
|
issue := &types.Issue{
|
|
Title: "Test issue",
|
|
Description: "Test description",
|
|
IssueType: types.TypeTask,
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
}
|
|
|
|
// Create issue
|
|
if err := env.CreateIssue(issue); err != nil {
|
|
t.Fatalf("[%s] CreateIssue failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
if issue.ID == "" {
|
|
t.Fatalf("[%s] issue ID not set after creation", env.Mode())
|
|
}
|
|
|
|
// Retrieve issue
|
|
got, err := env.GetIssue(issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("[%s] GetIssue failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
// Verify
|
|
if got.Title != "Test issue" {
|
|
t.Errorf("[%s] expected title 'Test issue', got %q", env.Mode(), got.Title)
|
|
}
|
|
if got.Status != types.StatusOpen {
|
|
t.Errorf("[%s] expected status 'open', got %q", env.Mode(), got.Status)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDualMode_UpdateIssue tests updating issues works in both modes
|
|
func TestDualMode_UpdateIssue(t *testing.T) {
|
|
RunDualModeTest(t, "update_issue", func(t *testing.T, env *DualModeTestEnv) {
|
|
// Create issue
|
|
issue := &types.Issue{
|
|
Title: "Original title",
|
|
IssueType: types.TypeTask,
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
}
|
|
if err := env.CreateIssue(issue); err != nil {
|
|
t.Fatalf("[%s] CreateIssue failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
// Update issue
|
|
updates := map[string]interface{}{
|
|
"title": "Updated title",
|
|
"status": types.StatusInProgress,
|
|
}
|
|
if err := env.UpdateIssue(issue.ID, updates); err != nil {
|
|
t.Fatalf("[%s] UpdateIssue failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
// Verify update
|
|
got, err := env.GetIssue(issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("[%s] GetIssue failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
if got.Title != "Updated title" {
|
|
t.Errorf("[%s] expected title 'Updated title', got %q", env.Mode(), got.Title)
|
|
}
|
|
if got.Status != types.StatusInProgress {
|
|
t.Errorf("[%s] expected status 'in_progress', got %q", env.Mode(), got.Status)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDualMode_Dependencies tests dependency operations in both modes
|
|
func TestDualMode_Dependencies(t *testing.T) {
|
|
RunDualModeTest(t, "dependencies", func(t *testing.T, env *DualModeTestEnv) {
|
|
// Create two issues
|
|
blocker := &types.Issue{
|
|
Title: "Blocker issue",
|
|
IssueType: types.TypeTask,
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
}
|
|
blocked := &types.Issue{
|
|
Title: "Blocked issue",
|
|
IssueType: types.TypeTask,
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
}
|
|
|
|
if err := env.CreateIssue(blocker); err != nil {
|
|
t.Fatalf("[%s] CreateIssue(blocker) failed: %v", env.Mode(), err)
|
|
}
|
|
if err := env.CreateIssue(blocked); err != nil {
|
|
t.Fatalf("[%s] CreateIssue(blocked) failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
// Add blocking dependency
|
|
if err := env.AddDependency(blocked.ID, blocker.ID, types.DepBlocks); err != nil {
|
|
t.Fatalf("[%s] AddDependency failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
// Verify blocked issue is not in ready queue
|
|
ready, err := env.GetReadyWork()
|
|
if err != nil {
|
|
t.Fatalf("[%s] GetReadyWork failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
for _, r := range ready {
|
|
if r.ID == blocked.ID {
|
|
t.Errorf("[%s] blocked issue should not be in ready queue", env.Mode())
|
|
}
|
|
}
|
|
|
|
// Verify blocker is in ready queue (it has no blockers)
|
|
found := false
|
|
for _, r := range ready {
|
|
if r.ID == blocker.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("[%s] blocker issue should be in ready queue", env.Mode())
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDualMode_ListIssues tests listing issues works in both modes
|
|
func TestDualMode_ListIssues(t *testing.T) {
|
|
RunDualModeTest(t, "list_issues", func(t *testing.T, env *DualModeTestEnv) {
|
|
// Create multiple issues
|
|
for i := 0; i < 3; i++ {
|
|
issue := &types.Issue{
|
|
Title: fmt.Sprintf("Issue %d", i),
|
|
IssueType: types.TypeTask,
|
|
Status: types.StatusOpen,
|
|
Priority: i + 1,
|
|
}
|
|
if err := env.CreateIssue(issue); err != nil {
|
|
t.Fatalf("[%s] CreateIssue failed: %v", env.Mode(), err)
|
|
}
|
|
}
|
|
|
|
// List all issues
|
|
issues, err := env.ListIssues(types.IssueFilter{})
|
|
if err != nil {
|
|
t.Fatalf("[%s] ListIssues failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
if len(issues) != 3 {
|
|
t.Errorf("[%s] expected 3 issues, got %d", env.Mode(), len(issues))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDualMode_Labels tests label operations in both modes
|
|
func TestDualMode_Labels(t *testing.T) {
|
|
RunDualModeTest(t, "labels", func(t *testing.T, env *DualModeTestEnv) {
|
|
// Create issue
|
|
issue := &types.Issue{
|
|
Title: "Issue with labels",
|
|
IssueType: types.TypeBug,
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
}
|
|
if err := env.CreateIssue(issue); err != nil {
|
|
t.Fatalf("[%s] CreateIssue failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
// Add label
|
|
if err := env.AddLabel(issue.ID, "critical"); err != nil {
|
|
t.Fatalf("[%s] AddLabel failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
// Verify label was added by fetching the issue
|
|
got, err := env.GetIssue(issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("[%s] GetIssue failed: %v", env.Mode(), err)
|
|
}
|
|
|
|
// Note: Label verification depends on whether the Show RPC returns labels
|
|
// This test primarily verifies the AddLabel operation doesn't error
|
|
_ = got // Use the retrieved issue for future label verification
|
|
})
|
|
}
|