//go:build integration // +build integration package main import ( "os" "path/filepath" "runtime" "strings" "sync" "testing" "time" ) // ============================================================================= // TEST HARNESS: Periodic Remote Sync in Event-Driven Mode // ============================================================================= // // These tests validate that the event-driven daemon periodically pulls from // remote to check for updates from other clones. This is essential for // multi-clone workflows where one clone pushes changes that other clones // need to receive. // // WITHOUT THE FIX: Daemon only reacts to local file changes, never pulls remote // WITH THE FIX: Daemon periodically calls doAutoImport to pull from remote // TestEventDrivenLoop_HasRemoteSyncTicker validates that the event loop code // includes a remoteSyncTicker for periodic remote sync. func TestEventDrivenLoop_HasRemoteSyncTicker(t *testing.T) { // Read the daemon_event_loop.go file and check for remoteSyncTicker content, err := os.ReadFile("daemon_event_loop.go") if err != nil { t.Fatalf("Failed to read daemon_event_loop.go: %v", err) } code := string(content) // Check for the remoteSyncTicker variable if !strings.Contains(code, "remoteSyncTicker") { t.Fatal("remoteSyncTicker not found in event loop - periodic sync not implemented") } // Check for periodic sync in select cases if !strings.Contains(code, "remoteSyncTicker.C") { t.Fatal("remoteSyncTicker.C not found in select statement - ticker not wired up") } // Check for doAutoImport call in the ticker case if !strings.Contains(code, "doAutoImport()") { t.Fatal("doAutoImport() not called - periodic sync not performing imports") } t.Log("Event loop correctly includes remoteSyncTicker for periodic remote sync") } // TestGetRemoteSyncInterval_Default validates that the default interval is used // when no environment variable is set. func TestGetRemoteSyncInterval_Default(t *testing.T) { // Ensure env var is not set os.Unsetenv("BEADS_REMOTE_SYNC_INTERVAL") log := createTestLogger(t) interval := getRemoteSyncInterval(log) if interval != DefaultRemoteSyncInterval { t.Errorf("Expected default interval %v, got %v", DefaultRemoteSyncInterval, interval) } if interval != 30*time.Second { t.Errorf("Expected 30s default, got %v", interval) } } // TestGetRemoteSyncInterval_CustomValue validates that custom intervals are parsed. func TestGetRemoteSyncInterval_CustomValue(t *testing.T) { tests := []struct { name string envValue string expected time.Duration }{ {"1 minute", "1m", 1 * time.Minute}, {"5 minutes", "5m", 5 * time.Minute}, {"60 seconds", "60s", 60 * time.Second}, {"10 seconds", "10s", 10 * time.Second}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { os.Setenv("BEADS_REMOTE_SYNC_INTERVAL", tc.envValue) defer os.Unsetenv("BEADS_REMOTE_SYNC_INTERVAL") log := createTestLogger(t) interval := getRemoteSyncInterval(log) if interval != tc.expected { t.Errorf("Expected %v, got %v", tc.expected, interval) } }) } } // TestGetRemoteSyncInterval_MinimumEnforced validates that intervals below 5s // are clamped to the minimum. func TestGetRemoteSyncInterval_MinimumEnforced(t *testing.T) { os.Setenv("BEADS_REMOTE_SYNC_INTERVAL", "1s") defer os.Unsetenv("BEADS_REMOTE_SYNC_INTERVAL") log := createTestLogger(t) interval := getRemoteSyncInterval(log) if interval != 5*time.Second { t.Errorf("Expected minimum 5s, got %v", interval) } } // TestGetRemoteSyncInterval_InvalidValue validates that invalid values fall back // to the default. func TestGetRemoteSyncInterval_InvalidValue(t *testing.T) { os.Setenv("BEADS_REMOTE_SYNC_INTERVAL", "not-a-duration") defer os.Unsetenv("BEADS_REMOTE_SYNC_INTERVAL") log := createTestLogger(t) interval := getRemoteSyncInterval(log) if interval != DefaultRemoteSyncInterval { t.Errorf("Expected default interval on invalid value, got %v", interval) } } // TestGetRemoteSyncInterval_Zero validates that zero disables periodic sync. func TestGetRemoteSyncInterval_Zero(t *testing.T) { os.Setenv("BEADS_REMOTE_SYNC_INTERVAL", "0") defer os.Unsetenv("BEADS_REMOTE_SYNC_INTERVAL") log := createTestLogger(t) interval := getRemoteSyncInterval(log) // Zero should return a very large interval (effectively disabled) if interval < 24*time.Hour { t.Errorf("Expected very large interval when disabled, got %v", interval) } } // TestPeriodicRemoteSync_DoAutoImportWiring validates that doAutoImport // is correctly wired up to be called by the periodic sync mechanism. func TestPeriodicRemoteSync_DoAutoImportWiring(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } // Track if doAutoImport was called var importCalled bool var mu sync.Mutex doAutoImport := func() { mu.Lock() importCalled = true mu.Unlock() } // Simulate what the event loop does on periodic sync doAutoImport() mu.Lock() called := importCalled mu.Unlock() if !called { t.Fatal("doAutoImport was not called - periodic sync wiring broken") } t.Log("doAutoImport function is correctly callable for periodic sync") } // TestSyncBranchPull_FetchesRemoteUpdates validates that the sync branch pull // mechanism correctly fetches updates from remote. func TestSyncBranchPull_FetchesRemoteUpdates(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } if runtime.GOOS == "windows" { t.Skip("Skipping on Windows") } // Create a remote and clone setup remoteDir := t.TempDir() runGitCmd(t, remoteDir, "init", "--bare") // Clone 1: Creates initial content and pushes clone1Dir := t.TempDir() runGitCmd(t, clone1Dir, "clone", remoteDir, ".") runGitCmd(t, clone1Dir, "config", "user.email", "test@example.com") runGitCmd(t, clone1Dir, "config", "user.name", "Test User") initMainBranchForSyncTest(t, clone1Dir) runGitCmd(t, clone1Dir, "push", "-u", "origin", "main") runGitCmd(t, clone1Dir, "checkout", "-b", "beads-sync") beadsDir1 := filepath.Join(clone1Dir, ".beads") if err := os.MkdirAll(beadsDir1, 0755); err != nil { t.Fatal(err) } initialContent := `{"id":"issue-1","title":"First issue"}` if err := os.WriteFile(filepath.Join(beadsDir1, "issues.jsonl"), []byte(initialContent+"\n"), 0644); err != nil { t.Fatal(err) } runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl") runGitCmd(t, clone1Dir, "commit", "-m", "Initial issue") runGitCmd(t, clone1Dir, "push", "-u", "origin", "beads-sync") // Clone 2: Fetches sync branch clone2Dir := t.TempDir() runGitCmd(t, clone2Dir, "clone", remoteDir, ".") runGitCmd(t, clone2Dir, "config", "user.email", "test@example.com") runGitCmd(t, clone2Dir, "config", "user.name", "Test User") runGitCmd(t, clone2Dir, "fetch", "origin", "beads-sync:beads-sync") // Create worktree in clone2 worktreePath := filepath.Join(clone2Dir, ".git", "beads-worktrees", "beads-sync") if err := os.MkdirAll(filepath.Dir(worktreePath), 0755); err != nil { t.Fatal(err) } runGitCmd(t, clone2Dir, "worktree", "add", worktreePath, "beads-sync") // Clone 1 pushes MORE content runGitCmd(t, clone1Dir, "checkout", "beads-sync") updatedContent := initialContent + "\n" + `{"id":"issue-2","title":"Second issue"}` if err := os.WriteFile(filepath.Join(beadsDir1, "issues.jsonl"), []byte(updatedContent+"\n"), 0644); err != nil { t.Fatal(err) } runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl") runGitCmd(t, clone1Dir, "commit", "-m", "Second issue") runGitCmd(t, clone1Dir, "push", "origin", "beads-sync") // Clone 2's worktree should NOT have the second issue yet worktreeJSONL := filepath.Join(worktreePath, ".beads", "issues.jsonl") beforePull, _ := os.ReadFile(worktreeJSONL) if strings.Contains(string(beforePull), "issue-2") { t.Log("Worktree already has issue-2 (unexpected)") } else { t.Log("Worktree does NOT have issue-2 (expected before pull)") } // Now pull in the worktree (simulating what syncBranchPull does) runGitCmd(t, worktreePath, "pull", "origin", "beads-sync") // Clone 2's worktree SHOULD now have the second issue afterPull, _ := os.ReadFile(worktreeJSONL) if !strings.Contains(string(afterPull), "issue-2") { t.Fatal("After pull, worktree still doesn't have issue-2 - sync branch pull broken") } t.Log("Sync branch pull correctly fetches remote updates") } // ============================================================================= // AUTO-PULL CONFIGURATION TESTS // ============================================================================= // TestAutoPullGatesRemoteSyncTicker validates that the remoteSyncTicker is only // created when autoPull is true. func TestAutoPullGatesRemoteSyncTicker(t *testing.T) { // Read the daemon_event_loop.go file and check for autoPull gating content, err := os.ReadFile("daemon_event_loop.go") if err != nil { t.Fatalf("Failed to read daemon_event_loop.go: %v", err) } code := string(content) // Check that remoteSyncTicker is gated on autoPull if !strings.Contains(code, "if autoPull") { t.Fatal("autoPull check not found - remoteSyncTicker not gated on autoPull") } // Check that autoPull parameter exists in function signature if !strings.Contains(code, "autoPull bool") { t.Fatal("autoPull bool parameter not found in runEventDrivenLoop signature") } // Check for disabled message when autoPull is false if !strings.Contains(code, "Auto-pull disabled") { t.Fatal("Auto-pull disabled message not found") } t.Log("remoteSyncTicker is correctly gated on autoPull parameter") } // TestAutoPullDefaultBehavior validates that auto_pull defaults to true when // sync.branch is configured. func TestAutoPullDefaultBehavior(t *testing.T) { // Read daemon.go and check for default behavior content, err := os.ReadFile("daemon.go") if err != nil { t.Fatalf("Failed to read daemon.go: %v", err) } code := string(content) // Check that auto_pull reads from daemon.auto_pull config if !strings.Contains(code, "daemon.auto_pull") { t.Fatal("daemon.auto_pull config check not found") } // Check that auto_pull defaults based on sync.branch if !strings.Contains(code, "sync.branch") { t.Fatal("sync.branch check for auto_pull default not found") } // Check for BEADS_AUTO_PULL environment variable if !strings.Contains(code, "BEADS_AUTO_PULL") { t.Fatal("BEADS_AUTO_PULL environment variable not checked") } t.Log("auto_pull correctly defaults to true when sync.branch is configured") } // ============================================================================= // HELPER FUNCTIONS // ============================================================================= func initMainBranchForSyncTest(t *testing.T, dir string) { t.Helper() readme := filepath.Join(dir, "README.md") if err := os.WriteFile(readme, []byte("# Test Repository\n"), 0644); err != nil { t.Fatalf("Failed to write README: %v", err) } runGitCmd(t, dir, "add", "README.md") runGitCmd(t, dir, "commit", "-m", "Initial commit") }