Files
gastown/internal/dog/manager_integration_test.go
2026-01-21 19:31:57 -08:00

576 lines
17 KiB
Go

package dog
import (
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/gastown/internal/config"
)
// =============================================================================
// Integration Test Helpers
// =============================================================================
// skipIfNoGit skips the test if git is not available.
func skipIfNoGit(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available, skipping integration test")
}
}
// testTownWithGitRigs creates a complete test town with git repositories.
// Sets up bare repos and mayor worktrees to simulate real Gas Town structure.
func testTownWithGitRigs(t *testing.T) (*Manager, string) {
t.Helper()
skipIfNoGit(t)
tmpDir := t.TempDir()
// Create rigs config
rigsConfig := &config.RigsConfig{
Version: 1,
Rigs: map[string]config.RigEntry{
"testrig": {GitURL: "local://testrig"},
},
}
// Set up rig structure with bare repo
rigPath := filepath.Join(tmpDir, "testrig")
bareRepoPath := filepath.Join(rigPath, ".repo.git")
// Initialize bare repo
if err := os.MkdirAll(bareRepoPath, 0755); err != nil {
t.Fatalf("Failed to create bare repo dir: %v", err)
}
// git init --bare
cmd := exec.Command("git", "init", "--bare", bareRepoPath)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to init bare repo: %v\n%s", err, out)
}
// Create mayor/rig worktree with initial commit
mayorPath := filepath.Join(rigPath, "mayor", "rig")
if err := os.MkdirAll(mayorPath, 0755); err != nil {
t.Fatalf("Failed to create mayor dir: %v", err)
}
// Initialize mayor/rig as a regular repo
cmd = exec.Command("git", "init", mayorPath)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to init mayor repo: %v\n%s", err, out)
}
// Configure git user for commits
cmd = exec.Command("git", "-C", mayorPath, "config", "user.email", "test@test.com")
cmd.Run()
cmd = exec.Command("git", "-C", mayorPath, "config", "user.name", "Test")
cmd.Run()
// Create initial commit in mayor/rig
readmePath := filepath.Join(mayorPath, "README.md")
if err := os.WriteFile(readmePath, []byte("# Test Rig\n"), 0644); err != nil {
t.Fatalf("Failed to write README: %v", err)
}
cmd = exec.Command("git", "-C", mayorPath, "add", ".")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to git add: %v\n%s", err, out)
}
cmd = exec.Command("git", "-C", mayorPath, "commit", "-m", "Initial commit")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to git commit: %v\n%s", err, out)
}
// Set up bare repo with proper remote configuration
// Add mayor/rig as a remote to bare repo and fetch
cmd = exec.Command("git", "-C", bareRepoPath, "remote", "add", "origin", mayorPath)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to add remote: %v\n%s", err, out)
}
cmd = exec.Command("git", "-C", bareRepoPath, "fetch", "origin")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to fetch: %v\n%s", err, out)
}
// Create refs/remotes/origin/main so worktrees can use origin/main
cmd = exec.Command("git", "-C", bareRepoPath, "branch", "main", "FETCH_HEAD")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to create main branch: %v\n%s", err, out)
}
cmd = exec.Command("git", "-C", bareRepoPath, "update-ref", "refs/remotes/origin/main", "FETCH_HEAD")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to update origin/main ref: %v\n%s", err, out)
}
m := NewManager(tmpDir, rigsConfig)
return m, tmpDir
}
// =============================================================================
// Add (Spawn) Integration Tests
// =============================================================================
func TestManager_Add_Integration_CreatesWorktrees(t *testing.T) {
m, tmpDir := testTownWithGitRigs(t)
dog, err := m.Add("alpha")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
// Verify dog was created
if dog.Name != "alpha" {
t.Errorf("Dog.Name = %q, want 'alpha'", dog.Name)
}
if dog.State != StateIdle {
t.Errorf("Dog.State = %q, want StateIdle", dog.State)
}
// Verify dog directory exists
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "alpha")
if _, err := os.Stat(dogPath); os.IsNotExist(err) {
t.Error("Dog directory was not created")
}
// Verify state file exists
statePath := filepath.Join(dogPath, ".dog.json")
if _, err := os.Stat(statePath); os.IsNotExist(err) {
t.Error("State file was not created")
}
// Verify worktree was created for testrig
if worktreePath, ok := dog.Worktrees["testrig"]; ok {
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Errorf("Worktree path %s does not exist", worktreePath)
}
// Verify it's a valid git worktree
cmd := exec.Command("git", "-C", worktreePath, "status")
if err := cmd.Run(); err != nil {
t.Errorf("Worktree is not a valid git repo: %v", err)
}
} else {
t.Error("Worktrees map missing 'testrig' entry")
}
// Verify timestamps are set
if dog.CreatedAt.IsZero() {
t.Error("CreatedAt was not set")
}
if dog.LastActive.IsZero() {
t.Error("LastActive was not set")
}
}
func TestManager_Add_Integration_SetsUpBranch(t *testing.T) {
m, _ := testTownWithGitRigs(t)
dog, err := m.Add("bravo")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
// Verify branch was created with correct naming pattern
worktreePath := dog.Worktrees["testrig"]
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--abbrev-ref", "HEAD")
out, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to get branch name: %v", err)
}
branch := string(out)
// Branch should be dog/<name>-<rig>-<timestamp>
if len(branch) < 10 || branch[:4] != "dog/" {
t.Errorf("Branch name %q doesn't match expected pattern dog/<name>-<rig>-<timestamp>", branch)
}
}
func TestManager_Add_Integration_CanAddMultipleDogs(t *testing.T) {
m, _ := testTownWithGitRigs(t)
names := []string{"alpha", "beta", "gamma"}
dogs := make([]*Dog, 0, len(names))
for _, name := range names {
dog, err := m.Add(name)
if err != nil {
t.Fatalf("Add(%q) error = %v", name, err)
}
dogs = append(dogs, dog)
}
// Verify all dogs exist
listed, err := m.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(listed) != len(names) {
t.Errorf("List() returned %d dogs, want %d", len(listed), len(names))
}
// Verify each dog has unique worktree paths
paths := make(map[string]string)
for _, dog := range dogs {
for rig, path := range dog.Worktrees {
key := rig + ":" + path
if existing, ok := paths[key]; ok {
t.Errorf("Duplicate worktree path: %s used by both %s and %s", path, existing, dog.Name)
}
paths[key] = dog.Name
}
}
}
// =============================================================================
// Remove (Kill) Integration Tests
// =============================================================================
func TestManager_Remove_Integration_CleansUpWorktrees(t *testing.T) {
m, tmpDir := testTownWithGitRigs(t)
// First add a dog
dog, err := m.Add("doomed")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "doomed")
worktreePath := dog.Worktrees["testrig"]
// Verify dog and worktree exist
if _, err := os.Stat(dogPath); os.IsNotExist(err) {
t.Fatal("Dog directory should exist before Remove")
}
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Fatal("Worktree should exist before Remove")
}
// Remove the dog
if err := m.Remove("doomed"); err != nil {
t.Fatalf("Remove() error = %v", err)
}
// Verify dog directory was cleaned up
if _, err := os.Stat(dogPath); !os.IsNotExist(err) {
t.Error("Dog directory should not exist after Remove")
}
// Verify worktree was removed (directory gone or not a git repo)
if _, err := os.Stat(worktreePath); err == nil {
// Directory still exists - check if it's still a git repo
cmd := exec.Command("git", "-C", worktreePath, "status")
if err := cmd.Run(); err == nil {
t.Error("Worktree should be removed or invalid after Remove")
}
}
}
func TestManager_Remove_Integration_DoesNotAffectOtherDogs(t *testing.T) {
m, _ := testTownWithGitRigs(t)
// Add two dogs
dog1, err := m.Add("survivor")
if err != nil {
t.Fatalf("Add(survivor) error = %v", err)
}
_, err = m.Add("victim")
if err != nil {
t.Fatalf("Add(victim) error = %v", err)
}
// Remove victim
if err := m.Remove("victim"); err != nil {
t.Fatalf("Remove(victim) error = %v", err)
}
// Verify survivor still works
survivor, err := m.Get("survivor")
if err != nil {
t.Fatalf("Get(survivor) error = %v after removing victim", err)
}
if survivor.Name != "survivor" {
t.Errorf("Survivor name changed to %q", survivor.Name)
}
// Verify survivor's worktree still works
worktreePath := dog1.Worktrees["testrig"]
cmd := exec.Command("git", "-C", worktreePath, "status")
if err := cmd.Run(); err != nil {
t.Errorf("Survivor's worktree is broken after removing another dog: %v", err)
}
}
// =============================================================================
// Full Lifecycle Integration Tests
// =============================================================================
func TestManager_Integration_FullLifecycle(t *testing.T) {
m, _ := testTownWithGitRigs(t)
// 1. Add (spawn)
dog, err := m.Add("lifecycle")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
if dog.State != StateIdle {
t.Errorf("Initial state = %q, want StateIdle", dog.State)
}
// 2. Assign work (working state)
if err := m.AssignWork("lifecycle", "task-123"); err != nil {
t.Fatalf("AssignWork() error = %v", err)
}
dog, _ = m.Get("lifecycle")
if dog.State != StateWorking {
t.Errorf("After AssignWork: state = %q, want StateWorking", dog.State)
}
if dog.Work != "task-123" {
t.Errorf("After AssignWork: work = %q, want 'task-123'", dog.Work)
}
// 3. Clear work (back to idle)
if err := m.ClearWork("lifecycle"); err != nil {
t.Fatalf("ClearWork() error = %v", err)
}
dog, _ = m.Get("lifecycle")
if dog.State != StateIdle {
t.Errorf("After ClearWork: state = %q, want StateIdle", dog.State)
}
// 4. Remove (kill)
if err := m.Remove("lifecycle"); err != nil {
t.Fatalf("Remove() error = %v", err)
}
_, err = m.Get("lifecycle")
if err != ErrDogNotFound {
t.Errorf("After Remove: Get() error = %v, want ErrDogNotFound", err)
}
}
func TestManager_Integration_ConcurrentStateChanges(t *testing.T) {
m, _ := testTownWithGitRigs(t)
_, err := m.Add("concurrent")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
// Rapid state changes should not corrupt state
for i := 0; i < 10; i++ {
if err := m.AssignWork("concurrent", "task"); err != nil {
t.Fatalf("AssignWork iteration %d error = %v", i, err)
}
if err := m.ClearWork("concurrent"); err != nil {
t.Fatalf("ClearWork iteration %d error = %v", i, err)
}
}
// Final state should be consistent
dog, err := m.Get("concurrent")
if err != nil {
t.Fatalf("Final Get() error = %v", err)
}
if dog.State != StateIdle {
t.Errorf("Final state = %q, want StateIdle", dog.State)
}
if dog.Work != "" {
t.Errorf("Final work = %q, want empty", dog.Work)
}
}
// =============================================================================
// Refresh Integration Tests
// =============================================================================
func TestManager_Refresh_Integration_RecreatesWorktrees(t *testing.T) {
m, _ := testTownWithGitRigs(t)
dog, err := m.Add("refresh-test")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
oldWorktreePath := dog.Worktrees["testrig"]
oldBranch := ""
// Get old branch name
cmd := exec.Command("git", "-C", oldWorktreePath, "rev-parse", "--abbrev-ref", "HEAD")
if out, err := cmd.Output(); err == nil {
oldBranch = string(out)
}
// Small delay to ensure timestamp-based branch names differ
time.Sleep(10 * time.Millisecond)
// Refresh
if err := m.Refresh("refresh-test"); err != nil {
t.Fatalf("Refresh() error = %v", err)
}
// Get updated dog
dog, err = m.Get("refresh-test")
if err != nil {
t.Fatalf("Get() after Refresh error = %v", err)
}
newWorktreePath := dog.Worktrees["testrig"]
// Verify new worktree exists
if _, err := os.Stat(newWorktreePath); os.IsNotExist(err) {
t.Error("New worktree should exist after Refresh")
}
// Verify it's a valid git worktree
cmd = exec.Command("git", "-C", newWorktreePath, "status")
if err := cmd.Run(); err != nil {
t.Errorf("New worktree is not a valid git repo: %v", err)
}
// Branch should be different (new timestamp)
cmd = exec.Command("git", "-C", newWorktreePath, "rev-parse", "--abbrev-ref", "HEAD")
if out, err := cmd.Output(); err == nil {
newBranch := string(out)
if newBranch == oldBranch {
t.Log("Note: Branch names are the same (timestamps may have collided)")
}
}
}
// =============================================================================
// RefreshRig Integration Tests
// =============================================================================
func TestManager_RefreshRig_Integration_RecreatesSingleWorktree(t *testing.T) {
m, _ := testTownWithGitRigs(t)
dog, err := m.Add("refreshrig-test")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
oldWorktreePath := dog.Worktrees["testrig"]
// Small delay to ensure timestamp-based branch names differ
time.Sleep(10 * time.Millisecond)
// RefreshRig just for testrig
if err := m.RefreshRig("refreshrig-test", "testrig"); err != nil {
t.Fatalf("RefreshRig() error = %v", err)
}
// Get updated dog
dog, err = m.Get("refreshrig-test")
if err != nil {
t.Fatalf("Get() after RefreshRig error = %v", err)
}
newWorktreePath := dog.Worktrees["testrig"]
// Verify new worktree exists and is valid
if _, err := os.Stat(newWorktreePath); os.IsNotExist(err) {
t.Error("New worktree should exist after RefreshRig")
}
cmd := exec.Command("git", "-C", newWorktreePath, "status")
if err := cmd.Run(); err != nil {
t.Errorf("New worktree is not a valid git repo: %v", err)
}
// Verify old worktree path is either gone or no longer valid
if oldWorktreePath != newWorktreePath {
// Paths differ - old should be cleaned up
if _, err := os.Stat(oldWorktreePath); err == nil {
cmd = exec.Command("git", "-C", oldWorktreePath, "status")
if cmd.Run() == nil {
t.Log("Note: Old worktree still exists (may be same path with new branch)")
}
}
}
}
// =============================================================================
// CleanupStaleBranches Integration Tests
// =============================================================================
func TestManager_CleanupStaleBranches_Integration_NoStaleBranches(t *testing.T) {
m, _ := testTownWithGitRigs(t)
// Add a dog (creates branches)
_, err := m.Add("active-dog")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
// Cleanup should find no stale branches (dog is still active)
deleted, err := m.CleanupStaleBranches()
if err != nil {
t.Fatalf("CleanupStaleBranches() error = %v", err)
}
if deleted != 0 {
t.Errorf("CleanupStaleBranches() deleted %d branches, want 0 (no stale)", deleted)
}
}
func TestManager_CleanupStaleBranches_Integration_EmptyKennel(t *testing.T) {
m, _ := testTownWithGitRigs(t)
// No dogs added - should be a no-op
deleted, err := m.CleanupStaleBranches()
if err != nil {
t.Fatalf("CleanupStaleBranches() error = %v", err)
}
if deleted != 0 {
t.Errorf("CleanupStaleBranches() deleted %d branches with empty kennel, want 0", deleted)
}
}
// =============================================================================
// Error Recovery Integration Tests
// =============================================================================
func TestManager_Integration_RecoveryFromPartialState(t *testing.T) {
m, tmpDir := testTownWithGitRigs(t)
// Add a dog normally
_, err := m.Add("partial")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
// Manually corrupt the worktree (simulate crash during creation)
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "partial")
worktreePath := filepath.Join(dogPath, "testrig")
// Delete the worktree directory but keep state file
if err := os.RemoveAll(worktreePath); err != nil {
t.Fatalf("Failed to remove worktree: %v", err)
}
// Dog should still be retrievable
_, err = m.Get("partial")
if err != nil {
t.Fatalf("Get() error after corruption = %v", err)
}
// State management should still work
if err := m.SetState("partial", StateWorking); err != nil {
t.Errorf("SetState() error after corruption = %v", err)
}
// Remove should succeed and clean up remaining state
if err := m.Remove("partial"); err != nil {
t.Fatalf("Remove() error after corruption = %v", err)
}
// Verify complete cleanup
if _, err := os.Stat(dogPath); !os.IsNotExist(err) {
t.Error("Dog directory should be fully removed")
}
}