diff --git a/internal/agent/state_test.go b/internal/agent/state_test.go new file mode 100644 index 00000000..50a5958f --- /dev/null +++ b/internal/agent/state_test.go @@ -0,0 +1,189 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStateConstants(t *testing.T) { + tests := []struct { + name string + state State + value string + }{ + {"StateStopped", StateStopped, "stopped"}, + {"StateRunning", StateRunning, "running"}, + {"StatePaused", StatePaused, "paused"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if string(tt.state) != tt.value { + t.Errorf("State constant = %q, want %q", tt.state, tt.value) + } + }) + } +} + +func TestStateManager_StateFile(t *testing.T) { + tmpDir := t.TempDir() + manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState { + return &TestState{Value: "default"} + }) + + expectedPath := filepath.Join(tmpDir, ".runtime", "test-state.json") + if manager.StateFile() != expectedPath { + t.Errorf("StateFile() = %q, want %q", manager.StateFile(), expectedPath) + } +} + +func TestStateManager_Load_NoFile(t *testing.T) { + tmpDir := t.TempDir() + manager := NewStateManager[TestState](tmpDir, "nonexistent.json", func() *TestState { + return &TestState{Value: "default"} + }) + + state, err := manager.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if state.Value != "default" { + t.Errorf("Load() value = %q, want %q", state.Value, "default") + } +} + +func TestStateManager_Load_Save_Load(t *testing.T) { + tmpDir := t.TempDir() + manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState { + return &TestState{Value: "default"} + }) + + // Save initial state + state := &TestState{Value: "test-value", Count: 42} + if err := manager.Save(state); err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Load it back + loaded, err := manager.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.Value != state.Value { + t.Errorf("Load() value = %q, want %q", loaded.Value, state.Value) + } + if loaded.Count != state.Count { + t.Errorf("Load() count = %d, want %d", loaded.Count, state.Count) + } +} + +func TestStateManager_Load_CreatesDirectory(t *testing.T) { + tmpDir := t.TempDir() + manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState { + return &TestState{Value: "default"} + }) + + // Save should create .runtime directory + state := &TestState{Value: "test"} + if err := manager.Save(state); err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Verify directory was created + runtimeDir := filepath.Join(tmpDir, ".runtime") + if _, err := os.Stat(runtimeDir); err != nil { + t.Errorf("Save() should create .runtime directory: %v", err) + } +} + +func TestStateManager_Load_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState { + return &TestState{Value: "default"} + }) + + // Write invalid JSON + statePath := manager.StateFile() + if err := os.MkdirAll(filepath.Dir(statePath), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + if err := os.WriteFile(statePath, []byte("invalid json"), 0644); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + + _, err := manager.Load() + if err == nil { + t.Error("Load() with invalid JSON should return error") + } +} + +func TestState_String(t *testing.T) { + tests := []struct { + state State + want string + }{ + {StateStopped, "stopped"}, + {StateRunning, "running"}, + {StatePaused, "paused"}, + } + + for _, tt := range tests { + if string(tt.state) != tt.want { + t.Errorf("State(%q) = %q, want %q", tt.state, string(tt.state), tt.want) + } + } +} + +func TestStateManager_GenericType(t *testing.T) { + // Test that StateManager works with different types + + type ComplexState struct { + Name string `json:"name"` + Values []int `json:"values"` + Enabled bool `json:"enabled"` + Nested struct { + X int `json:"x"` + } `json:"nested"` + } + + tmpDir := t.TempDir() + manager := NewStateManager[ComplexState](tmpDir, "complex.json", func() *ComplexState { + return &ComplexState{Name: "default", Values: []int{}} + }) + + original := &ComplexState{ + Name: "test", + Values: []int{1, 2, 3}, + Enabled: true, + } + original.Nested.X = 42 + + if err := manager.Save(original); err != nil { + t.Fatalf("Save() error = %v", err) + } + + loaded, err := manager.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if loaded.Name != original.Name { + t.Errorf("Name = %q, want %q", loaded.Name, original.Name) + } + if len(loaded.Values) != len(original.Values) { + t.Errorf("Values length = %d, want %d", len(loaded.Values), len(original.Values)) + } + if loaded.Enabled != original.Enabled { + t.Errorf("Enabled = %v, want %v", loaded.Enabled, original.Enabled) + } + if loaded.Nested.X != original.Nested.X { + t.Errorf("Nested.X = %d, want %d", loaded.Nested.X, original.Nested.X) + } +} + +// TestState is a simple type for testing +type TestState struct { + Value string `json:"value"` + Count int `json:"count"` +} diff --git a/internal/cmd/errors_test.go b/internal/cmd/errors_test.go new file mode 100644 index 00000000..0d694434 --- /dev/null +++ b/internal/cmd/errors_test.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "errors" + "fmt" + "testing" +) + +func TestSilentExitError_Error(t *testing.T) { + tests := []struct { + name string + code int + want string + }{ + {"zero code", 0, "exit 0"}, + {"success code", 1, "exit 1"}, + {"error code", 2, "exit 2"}, + {"custom code", 42, "exit 42"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &SilentExitError{Code: tt.code} + got := e.Error() + if got != tt.want { + t.Errorf("SilentExitError.Error() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNewSilentExit(t *testing.T) { + tests := []struct { + code int + }{ + {0}, + {1}, + {2}, + {127}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("code_%d", tt.code), func(t *testing.T) { + err := NewSilentExit(tt.code) + if err == nil { + t.Fatal("NewSilentExit should return non-nil") + } + if err.Code != tt.code { + t.Errorf("NewSilentExit(%d).Code = %d, want %d", tt.code, err.Code, tt.code) + } + }) + } +} + +func TestIsSilentExit(t *testing.T) { + tests := []struct { + name string + err error + wantCode int + wantIsSilent bool + }{ + {"nil error", nil, 0, false}, + {"silent exit code 0", NewSilentExit(0), 0, true}, + {"silent exit code 1", NewSilentExit(1), 1, true}, + {"silent exit code 2", NewSilentExit(2), 2, true}, + {"other error", errors.New("some error"), 0, false}, + // Note: wrapped errors require errors.As fix - see PR #462 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, isSilent := IsSilentExit(tt.err) + if isSilent != tt.wantIsSilent { + t.Errorf("IsSilentExit(%v) isSilent = %v, want %v", tt.err, isSilent, tt.wantIsSilent) + } + if code != tt.wantCode { + t.Errorf("IsSilentExit(%v) code = %d, want %d", tt.err, code, tt.wantCode) + } + }) + } +} + +func TestSilentExitError_Is(t *testing.T) { + // Test that SilentExitError works with errors.Is + err := NewSilentExit(1) + var target *SilentExitError + if !errors.As(err, &target) { + t.Error("errors.As should find SilentExitError") + } + if target.Code != 1 { + t.Errorf("errors.As extracted code = %d, want 1", target.Code) + } +} diff --git a/internal/crew/types_test.go b/internal/crew/types_test.go new file mode 100644 index 00000000..e2d0f694 --- /dev/null +++ b/internal/crew/types_test.go @@ -0,0 +1,100 @@ +package crew + +import ( + "encoding/json" + "testing" + "time" +) + +func TestCrewWorker_Summary(t *testing.T) { + now := time.Now() + worker := &CrewWorker{ + Name: "test-worker", + Rig: "gastown", + ClonePath: "/path/to/clone", + Branch: "main", + CreatedAt: now, + UpdatedAt: now, + } + + summary := worker.Summary() + + if summary.Name != worker.Name { + t.Errorf("Summary.Name = %q, want %q", summary.Name, worker.Name) + } + if summary.Branch != worker.Branch { + t.Errorf("Summary.Branch = %q, want %q", summary.Branch, worker.Branch) + } +} + +func TestCrewWorker_JSONMarshaling(t *testing.T) { + now := time.Now().Round(time.Second) // Round for JSON precision + worker := &CrewWorker{ + Name: "test-worker", + Rig: "gastown", + ClonePath: "/path/to/clone", + Branch: "feature-branch", + CreatedAt: now, + UpdatedAt: now, + } + + // Marshal to JSON + data, err := json.Marshal(worker) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + // Unmarshal back + var unmarshaled CrewWorker + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if unmarshaled.Name != worker.Name { + t.Errorf("After round-trip: Name = %q, want %q", unmarshaled.Name, worker.Name) + } + if unmarshaled.Rig != worker.Rig { + t.Errorf("After round-trip: Rig = %q, want %q", unmarshaled.Rig, worker.Rig) + } + if unmarshaled.Branch != worker.Branch { + t.Errorf("After round-trip: Branch = %q, want %q", unmarshaled.Branch, worker.Branch) + } +} + +func TestSummary_JSONMarshaling(t *testing.T) { + summary := Summary{ + Name: "worker-1", + Branch: "main", + } + + data, err := json.Marshal(summary) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var unmarshaled Summary + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if unmarshaled.Name != summary.Name { + t.Errorf("After round-trip: Name = %q, want %q", unmarshaled.Name, summary.Name) + } + if unmarshaled.Branch != summary.Branch { + t.Errorf("After round-trip: Branch = %q, want %q", unmarshaled.Branch, summary.Branch) + } +} + +func TestCrewWorker_ZeroValues(t *testing.T) { + var worker CrewWorker + + // Test zero value behavior + if worker.Name != "" { + t.Errorf("zero value CrewWorker.Name should be empty, got %q", worker.Name) + } + + summary := worker.Summary() + if summary.Name != "" { + t.Errorf("Summary of zero value CrewWorker should have empty Name, got %q", summary.Name) + } +} diff --git a/internal/doctor/errors_test.go b/internal/doctor/errors_test.go new file mode 100644 index 00000000..ba37c412 --- /dev/null +++ b/internal/doctor/errors_test.go @@ -0,0 +1,31 @@ +package doctor + +import ( + "errors" + "testing" +) + +func TestErrCannotFix(t *testing.T) { + // Test that ErrCannotFix is defined and has expected message + if ErrCannotFix == nil { + t.Fatal("ErrCannotFix should not be nil") + } + + expected := "check does not support auto-fix" + if ErrCannotFix.Error() != expected { + t.Errorf("ErrCannotFix.Error() = %q, want %q", ErrCannotFix.Error(), expected) + } +} + +func TestErrCannotFixIsError(t *testing.T) { + // Verify ErrCannotFix implements the error interface correctly + var err error = ErrCannotFix + if err == nil { + t.Fatal("ErrCannotFix should implement error interface") + } + + // Test errors.Is compatibility + if !errors.Is(ErrCannotFix, ErrCannotFix) { + t.Error("errors.Is should return true for ErrCannotFix") + } +} diff --git a/internal/dog/types_test.go b/internal/dog/types_test.go new file mode 100644 index 00000000..475598c4 --- /dev/null +++ b/internal/dog/types_test.go @@ -0,0 +1,148 @@ +package dog + +import ( + "encoding/json" + "testing" + "time" +) + +func TestState_Constants(t *testing.T) { + tests := []struct { + name string + state State + want string + }{ + {"idle state", StateIdle, "idle"}, + {"working state", StateWorking, "working"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if string(tt.state) != tt.want { + t.Errorf("State constant = %q, want %q", tt.state, tt.want) + } + }) + } +} + +func TestDog_ZeroValues(t *testing.T) { + var dog Dog + + // Test zero value behavior + if dog.Name != "" { + t.Errorf("zero value Dog.Name should be empty, got %q", dog.Name) + } + if dog.State != "" { + t.Errorf("zero value Dog.State should be empty, got %q", dog.State) + } + if dog.Worktrees == nil { + // Worktrees is a map, nil is expected for zero value + } else if len(dog.Worktrees) != 0 { + t.Errorf("zero value Dog.Worktrees should be empty, got %d items", len(dog.Worktrees)) + } +} + +func TestDogState_JSONMarshaling(t *testing.T) { + now := time.Now().Round(time.Second) + dogState := DogState{ + Name: "test-dog", + State: StateWorking, + LastActive: now, + Work: "hq-abc123", + Worktrees: map[string]string{"gastown": "/path/to/worktree"}, + CreatedAt: now, + UpdatedAt: now, + } + + // Marshal to JSON + data, err := json.Marshal(dogState) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + // Unmarshal back + var unmarshaled DogState + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if unmarshaled.Name != dogState.Name { + t.Errorf("After round-trip: Name = %q, want %q", unmarshaled.Name, dogState.Name) + } + if unmarshaled.State != dogState.State { + t.Errorf("After round-trip: State = %q, want %q", unmarshaled.State, dogState.State) + } + if unmarshaled.Work != dogState.Work { + t.Errorf("After round-trip: Work = %q, want %q", unmarshaled.Work, dogState.Work) + } +} + +func TestDogState_OmitEmptyFields(t *testing.T) { + dogState := DogState{ + Name: "test-dog", + State: StateIdle, + LastActive: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + // Work and Worktrees left empty to test omitempty + } + + data, err := json.Marshal(dogState) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + // Check that empty fields are omitted + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("json.Unmarshal() to map error = %v", err) + } + + if _, exists := raw["work"]; exists { + t.Error("Field 'work' should be omitted when empty") + } + if _, exists := raw["worktrees"]; exists { + t.Error("Field 'worktrees' should be omitted when empty") + } + + // Required fields should be present + requiredFields := []string{"name", "state", "last_active", "created_at", "updated_at"} + for _, field := range requiredFields { + if _, exists := raw[field]; !exists { + t.Errorf("Required field '%s' should be present", field) + } + } +} + +func TestDogState_WithWorktrees(t *testing.T) { + dogState := DogState{ + Name: "alpha", + State: StateWorking, + Worktrees: map[string]string{"gastown": "/path/1", "beads": "/path/2"}, + } + + data, err := json.Marshal(dogState) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("json.Unmarshal() to map error = %v", err) + } + + worktrees, exists := raw["worktrees"] + if !exists { + t.Fatal("Field 'worktrees' should be present when non-empty") + } + + // Verify it's a map + worktreesMap, ok := worktrees.(map[string]interface{}) + if !ok { + t.Fatal("worktrees should be a JSON object") + } + + if len(worktreesMap) != 2 { + t.Errorf("worktrees should have 2 entries, got %d", len(worktreesMap)) + } +} diff --git a/internal/mail/bd_test.go b/internal/mail/bd_test.go new file mode 100644 index 00000000..fe23616e --- /dev/null +++ b/internal/mail/bd_test.go @@ -0,0 +1,193 @@ +package mail + +import ( + "errors" + "testing" +) + +func TestBdError_Error(t *testing.T) { + tests := []struct { + name string + err *bdError + want string + }{ + { + name: "stderr present", + err: &bdError{ + Err: errors.New("some error"), + Stderr: "stderr output", + }, + want: "stderr output", + }, + { + name: "no stderr, has error", + err: &bdError{ + Err: errors.New("some error"), + Stderr: "", + }, + want: "some error", + }, + { + name: "no stderr, no error", + err: &bdError{ + Err: nil, + Stderr: "", + }, + want: "unknown bd error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.err.Error() + if got != tt.want { + t.Errorf("bdError.Error() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBdError_Unwrap(t *testing.T) { + originalErr := errors.New("original error") + bdErr := &bdError{ + Err: originalErr, + Stderr: "stderr output", + } + + unwrapped := bdErr.Unwrap() + if unwrapped != originalErr { + t.Errorf("bdError.Unwrap() = %v, want %v", unwrapped, originalErr) + } +} + +func TestBdError_UnwrapNil(t *testing.T) { + bdErr := &bdError{ + Err: nil, + Stderr: "", + } + + unwrapped := bdErr.Unwrap() + if unwrapped != nil { + t.Errorf("bdError.Unwrap() with nil Err should return nil, got %v", unwrapped) + } +} + +func TestBdError_ContainsError(t *testing.T) { + tests := []struct { + name string + err *bdError + substr string + contains bool + }{ + { + name: "substring present", + err: &bdError{ + Stderr: "error: bead not found", + }, + substr: "bead not found", + contains: true, + }, + { + name: "substring not present", + err: &bdError{ + Stderr: "error: bead not found", + }, + substr: "permission denied", + contains: false, + }, + { + name: "empty stderr", + err: &bdError{ + Stderr: "", + }, + substr: "anything", + contains: false, + }, + { + name: "case sensitive", + err: &bdError{ + Stderr: "Error: Bead Not Found", + }, + substr: "bead not found", + contains: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.err.ContainsError(tt.substr) + if got != tt.contains { + t.Errorf("bdError.ContainsError(%q) = %v, want %v", tt.substr, got, tt.contains) + } + }) + } +} + +func TestBdError_ContainsErrorPartialMatch(t *testing.T) { + err := &bdError{ + Stderr: "fatal: invalid bead ID format: expected prefix-#id", + } + + // Test partial matches + if !err.ContainsError("invalid bead ID") { + t.Error("Should contain partial substring") + } + if !err.ContainsError("fatal:") { + t.Error("Should contain prefix") + } + if !err.ContainsError("expected prefix") { + t.Error("Should contain suffix") + } +} + +func TestBdError_ContainsErrorSpecialChars(t *testing.T) { + err := &bdError{ + Stderr: "error: bead 'gt-123' not found (exit 1)", + } + + if !err.ContainsError("'gt-123'") { + t.Error("Should handle quotes in substring") + } + if !err.ContainsError("(exit 1)") { + t.Error("Should handle parentheses in substring") + } +} + +func TestBdError_ImplementsErrorInterface(t *testing.T) { + // Verify bdError implements error interface + var err error = &bdError{ + Err: errors.New("test"), + Stderr: "test stderr", + } + + _ = err.Error() // Should compile and not panic +} + +func TestBdError_WithAllFields(t *testing.T) { + originalErr := errors.New("original error") + bdErr := &bdError{ + Err: originalErr, + Stderr: "command failed: bead not found", + } + + // Test Error() returns stderr + got := bdErr.Error() + want := "command failed: bead not found" + if got != want { + t.Errorf("bdError.Error() = %q, want %q", got, want) + } + + // Test Unwrap() returns original error + unwrapped := bdErr.Unwrap() + if unwrapped != originalErr { + t.Errorf("bdError.Unwrap() = %v, want %v", unwrapped, originalErr) + } + + // Test ContainsError works + if !bdErr.ContainsError("bead not found") { + t.Error("ContainsError should find substring in stderr") + } + if bdErr.ContainsError("not present") { + t.Error("ContainsError should return false for non-existent substring") + } +} diff --git a/internal/opencode/plugin_test.go b/internal/opencode/plugin_test.go new file mode 100644 index 00000000..4840bf09 --- /dev/null +++ b/internal/opencode/plugin_test.go @@ -0,0 +1,153 @@ +package opencode + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnsurePluginAt_EmptyParameters(t *testing.T) { + // Test that empty pluginDir or pluginFile returns nil + t.Run("empty pluginDir", func(t *testing.T) { + err := EnsurePluginAt("/tmp/work", "", "plugin.js") + if err != nil { + t.Errorf("EnsurePluginAt() with empty pluginDir should return nil, got %v", err) + } + }) + + t.Run("empty pluginFile", func(t *testing.T) { + err := EnsurePluginAt("/tmp/work", "plugins", "") + if err != nil { + t.Errorf("EnsurePluginAt() with empty pluginFile should return nil, got %v", err) + } + }) + + t.Run("both empty", func(t *testing.T) { + err := EnsurePluginAt("/tmp/work", "", "") + if err != nil { + t.Errorf("EnsurePluginAt() with both empty should return nil, got %v", err) + } + }) +} + +func TestEnsurePluginAt_FileExists(t *testing.T) { + // Create a temporary directory + tmpDir := t.TempDir() + + // Create the plugin file first + pluginDir := "plugins" + pluginFile := "gastown.js" + pluginPath := filepath.Join(tmpDir, pluginDir, pluginFile) + + if err := os.MkdirAll(filepath.Dir(pluginPath), 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Write a placeholder file + existingContent := []byte("// existing plugin") + if err := os.WriteFile(pluginPath, existingContent, 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // EnsurePluginAt should not overwrite existing file + err := EnsurePluginAt(tmpDir, pluginDir, pluginFile) + if err != nil { + t.Fatalf("EnsurePluginAt() error = %v", err) + } + + // Verify file content is unchanged + content, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("Failed to read plugin file: %v", err) + } + if string(content) != string(existingContent) { + t.Error("EnsurePluginAt() should not overwrite existing file") + } +} + +func TestEnsurePluginAt_CreatesFile(t *testing.T) { + // Create a temporary directory + tmpDir := t.TempDir() + + pluginDir := "plugins" + pluginFile := "gastown.js" + pluginPath := filepath.Join(tmpDir, pluginDir, pluginFile) + + // Ensure plugin doesn't exist + if _, err := os.Stat(pluginPath); err == nil { + t.Fatal("Plugin file should not exist yet") + } + + // Create the plugin + err := EnsurePluginAt(tmpDir, pluginDir, pluginFile) + if err != nil { + t.Fatalf("EnsurePluginAt() error = %v", err) + } + + // Verify file was created + info, err := os.Stat(pluginPath) + if err != nil { + t.Fatalf("Plugin file was not created: %v", err) + } + if info.IsDir() { + t.Error("Plugin path should be a file, not a directory") + } + + // Verify file has content + content, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("Failed to read plugin file: %v", err) + } + if len(content) == 0 { + t.Error("Plugin file should have content") + } +} + +func TestEnsurePluginAt_CreatesDirectory(t *testing.T) { + // Create a temporary directory + tmpDir := t.TempDir() + + pluginDir := "nested/plugins/dir" + pluginFile := "gastown.js" + pluginPath := filepath.Join(tmpDir, pluginDir, pluginFile) + + // Create the plugin + err := EnsurePluginAt(tmpDir, pluginDir, pluginFile) + if err != nil { + t.Fatalf("EnsurePluginAt() error = %v", err) + } + + // Verify directory was created + dirInfo, err := os.Stat(filepath.Dir(pluginPath)) + if err != nil { + t.Fatalf("Plugin directory was not created: %v", err) + } + if !dirInfo.IsDir() { + t.Error("Plugin parent path should be a directory") + } +} + +func TestEnsurePluginAt_FilePermissions(t *testing.T) { + // Create a temporary directory + tmpDir := t.TempDir() + + pluginDir := "plugins" + pluginFile := "gastown.js" + pluginPath := filepath.Join(tmpDir, pluginDir, pluginFile) + + err := EnsurePluginAt(tmpDir, pluginDir, pluginFile) + if err != nil { + t.Fatalf("EnsurePluginAt() error = %v", err) + } + + info, err := os.Stat(pluginPath) + if err != nil { + t.Fatalf("Failed to stat plugin file: %v", err) + } + + // Check file mode is 0644 (rw-r--r--) + expectedMode := os.FileMode(0644) + if info.Mode() != expectedMode { + t.Errorf("Plugin file mode = %v, want %v", info.Mode(), expectedMode) + } +} diff --git a/internal/rig/overlay_test.go b/internal/rig/overlay_test.go new file mode 100644 index 00000000..b21768f5 --- /dev/null +++ b/internal/rig/overlay_test.go @@ -0,0 +1,251 @@ +package rig + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCopyOverlay_NoOverlayDirectory(t *testing.T) { + tmpDir := t.TempDir() + destDir := t.TempDir() + + // No overlay directory exists + err := CopyOverlay(tmpDir, destDir) + if err != nil { + t.Errorf("CopyOverlay() with no overlay directory should return nil, got %v", err) + } +} + +func TestCopyOverlay_CopiesFiles(t *testing.T) { + rigDir := t.TempDir() + destDir := t.TempDir() + + // Create overlay directory with test files + overlayDir := filepath.Join(rigDir, ".runtime", "overlay") + if err := os.MkdirAll(overlayDir, 0755); err != nil { + t.Fatalf("Failed to create overlay dir: %v", err) + } + + // Create test files + testFile1 := filepath.Join(overlayDir, "test1.txt") + testFile2 := filepath.Join(overlayDir, "test2.txt") + + if err := os.WriteFile(testFile1, []byte("content1"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + if err := os.WriteFile(testFile2, []byte("content2"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Copy overlay + err := CopyOverlay(rigDir, destDir) + if err != nil { + t.Fatalf("CopyOverlay() error = %v", err) + } + + // Verify files were copied + destFile1 := filepath.Join(destDir, "test1.txt") + destFile2 := filepath.Join(destDir, "test2.txt") + + content1, err := os.ReadFile(destFile1) + if err != nil { + t.Errorf("File test1.txt was not copied: %v", err) + } + if string(content1) != "content1" { + t.Errorf("test1.txt content = %q, want %q", string(content1), "content1") + } + + content2, err := os.ReadFile(destFile2) + if err != nil { + t.Errorf("File test2.txt was not copied: %v", err) + } + if string(content2) != "content2" { + t.Errorf("test2.txt content = %q, want %q", string(content2), "content2") + } +} + +func TestCopyOverlay_PreservesPermissions(t *testing.T) { + rigDir := t.TempDir() + destDir := t.TempDir() + + // Create overlay directory with a file + overlayDir := filepath.Join(rigDir, ".runtime", "overlay") + if err := os.MkdirAll(overlayDir, 0755); err != nil { + t.Fatalf("Failed to create overlay dir: %v", err) + } + + testFile := filepath.Join(overlayDir, "test.txt") + if err := os.WriteFile(testFile, []byte("content"), 0755); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Copy overlay + err := CopyOverlay(rigDir, destDir) + if err != nil { + t.Fatalf("CopyOverlay() error = %v", err) + } + + // Verify permissions were preserved + srcInfo, _ := os.Stat(testFile) + destInfo, err := os.Stat(filepath.Join(destDir, "test.txt")) + if err != nil { + t.Fatalf("Failed to stat destination file: %v", err) + } + + if srcInfo.Mode().Perm() != destInfo.Mode().Perm() { + t.Errorf("Permissions not preserved: src=%v, dest=%v", srcInfo.Mode(), destInfo.Mode()) + } +} + +func TestCopyOverlay_SkipsSubdirectories(t *testing.T) { + rigDir := t.TempDir() + destDir := t.TempDir() + + // Create overlay directory with a subdirectory + overlayDir := filepath.Join(rigDir, ".runtime", "overlay") + if err := os.MkdirAll(overlayDir, 0755); err != nil { + t.Fatalf("Failed to create overlay dir: %v", err) + } + + // Create a subdirectory + subDir := filepath.Join(overlayDir, "subdir") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + // Create a file in the overlay root + testFile := filepath.Join(overlayDir, "test.txt") + if err := os.WriteFile(testFile, []byte("content"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Create a file in the subdirectory + subFile := filepath.Join(subDir, "sub.txt") + if err := os.WriteFile(subFile, []byte("subcontent"), 0644); err != nil { + t.Fatalf("Failed to create sub file: %v", err) + } + + // Copy overlay + err := CopyOverlay(rigDir, destDir) + if err != nil { + t.Fatalf("CopyOverlay() error = %v", err) + } + + // Verify root file was copied + if _, err := os.Stat(filepath.Join(destDir, "test.txt")); err != nil { + t.Error("Root file should be copied") + } + + // Verify subdirectory was NOT copied + if _, err := os.Stat(filepath.Join(destDir, "subdir")); err == nil { + t.Error("Subdirectory should not be copied") + } + if _, err := os.Stat(filepath.Join(destDir, "subdir", "sub.txt")); err == nil { + t.Error("File in subdirectory should not be copied") + } +} + +func TestCopyOverlay_EmptyOverlay(t *testing.T) { + rigDir := t.TempDir() + destDir := t.TempDir() + + // Create empty overlay directory + overlayDir := filepath.Join(rigDir, ".runtime", "overlay") + if err := os.MkdirAll(overlayDir, 0755); err != nil { + t.Fatalf("Failed to create overlay dir: %v", err) + } + + // Copy overlay + err := CopyOverlay(rigDir, destDir) + if err != nil { + t.Fatalf("CopyOverlay() error = %v", err) + } + + // Should succeed without errors +} + +func TestCopyOverlay_OverwritesExisting(t *testing.T) { + rigDir := t.TempDir() + destDir := t.TempDir() + + // Create overlay directory with test file + overlayDir := filepath.Join(rigDir, ".runtime", "overlay") + if err := os.MkdirAll(overlayDir, 0755); err != nil { + t.Fatalf("Failed to create overlay dir: %v", err) + } + + testFile := filepath.Join(overlayDir, "test.txt") + if err := os.WriteFile(testFile, []byte("new content"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Create existing file in destination with different content + destFile := filepath.Join(destDir, "test.txt") + if err := os.WriteFile(destFile, []byte("old content"), 0644); err != nil { + t.Fatalf("Failed to create dest file: %v", err) + } + + // Copy overlay + err := CopyOverlay(rigDir, destDir) + if err != nil { + t.Fatalf("CopyOverlay() error = %v", err) + } + + // Verify file was overwritten + content, err := os.ReadFile(destFile) + if err != nil { + t.Fatalf("Failed to read dest file: %v", err) + } + if string(content) != "new content" { + t.Errorf("File content = %q, want %q", string(content), "new content") + } +} + +func TestCopyFilePreserveMode(t *testing.T) { + tmpDir := t.TempDir() + + // Create source file + srcFile := filepath.Join(tmpDir, "src.txt") + if err := os.WriteFile(srcFile, []byte("test content"), 0644); err != nil { + t.Fatalf("Failed to create src file: %v", err) + } + + // Copy file + dstFile := filepath.Join(tmpDir, "dst.txt") + err := copyFilePreserveMode(srcFile, dstFile) + if err != nil { + t.Fatalf("copyFilePreserveMode() error = %v", err) + } + + // Verify content + content, err := os.ReadFile(dstFile) + if err != nil { + t.Errorf("Failed to read dst file: %v", err) + } + if string(content) != "test content" { + t.Errorf("Content = %q, want %q", string(content), "test content") + } + + // Verify permissions + srcInfo, _ := os.Stat(srcFile) + dstInfo, err := os.Stat(dstFile) + if err != nil { + t.Fatalf("Failed to stat dst file: %v", err) + } + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + t.Errorf("Permissions not preserved: src=%v, dest=%v", srcInfo.Mode(), dstInfo.Mode()) + } +} + +func TestCopyFilePreserveMode_NonexistentSource(t *testing.T) { + tmpDir := t.TempDir() + + srcFile := filepath.Join(tmpDir, "nonexistent.txt") + dstFile := filepath.Join(tmpDir, "dst.txt") + + err := copyFilePreserveMode(srcFile, dstFile) + if err == nil { + t.Error("copyFilePreserveMode() with nonexistent source should return error") + } +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go new file mode 100644 index 00000000..79ed31cc --- /dev/null +++ b/internal/runtime/runtime_test.go @@ -0,0 +1,307 @@ +package runtime + +import ( + "os" + "testing" + "time" + + "github.com/steveyegge/gastown/internal/config" +) + +func TestSessionIDFromEnv_Default(t *testing.T) { + // Clear all environment variables + oldGSEnv := os.Getenv("GT_SESSION_ID_ENV") + oldClaudeID := os.Getenv("CLAUDE_SESSION_ID") + defer func() { + if oldGSEnv != "" { + os.Setenv("GT_SESSION_ID_ENV", oldGSEnv) + } else { + os.Unsetenv("GT_SESSION_ID_ENV") + } + if oldClaudeID != "" { + os.Setenv("CLAUDE_SESSION_ID", oldClaudeID) + } else { + os.Unsetenv("CLAUDE_SESSION_ID") + } + }() + os.Unsetenv("GT_SESSION_ID_ENV") + os.Unsetenv("CLAUDE_SESSION_ID") + + result := SessionIDFromEnv() + if result != "" { + t.Errorf("SessionIDFromEnv() with no env vars should return empty, got %q", result) + } +} + +func TestSessionIDFromEnv_ClaudeSessionID(t *testing.T) { + oldGSEnv := os.Getenv("GT_SESSION_ID_ENV") + oldClaudeID := os.Getenv("CLAUDE_SESSION_ID") + defer func() { + if oldGSEnv != "" { + os.Setenv("GT_SESSION_ID_ENV", oldGSEnv) + } else { + os.Unsetenv("GT_SESSION_ID_ENV") + } + if oldClaudeID != "" { + os.Setenv("CLAUDE_SESSION_ID", oldClaudeID) + } else { + os.Unsetenv("CLAUDE_SESSION_ID") + } + }() + + os.Unsetenv("GT_SESSION_ID_ENV") + os.Setenv("CLAUDE_SESSION_ID", "test-session-123") + + result := SessionIDFromEnv() + if result != "test-session-123" { + t.Errorf("SessionIDFromEnv() = %q, want %q", result, "test-session-123") + } +} + +func TestSessionIDFromEnv_CustomEnvVar(t *testing.T) { + oldGSEnv := os.Getenv("GT_SESSION_ID_ENV") + oldCustomID := os.Getenv("CUSTOM_SESSION_ID") + oldClaudeID := os.Getenv("CLAUDE_SESSION_ID") + defer func() { + if oldGSEnv != "" { + os.Setenv("GT_SESSION_ID_ENV", oldGSEnv) + } else { + os.Unsetenv("GT_SESSION_ID_ENV") + } + if oldCustomID != "" { + os.Setenv("CUSTOM_SESSION_ID", oldCustomID) + } else { + os.Unsetenv("CUSTOM_SESSION_ID") + } + if oldClaudeID != "" { + os.Setenv("CLAUDE_SESSION_ID", oldClaudeID) + } else { + os.Unsetenv("CLAUDE_SESSION_ID") + } + }() + + os.Setenv("GT_SESSION_ID_ENV", "CUSTOM_SESSION_ID") + os.Setenv("CUSTOM_SESSION_ID", "custom-session-456") + os.Setenv("CLAUDE_SESSION_ID", "claude-session-789") + + result := SessionIDFromEnv() + if result != "custom-session-456" { + t.Errorf("SessionIDFromEnv() with custom env = %q, want %q", result, "custom-session-456") + } +} + +func TestSleepForReadyDelay_NilConfig(t *testing.T) { + // Should not panic with nil config + SleepForReadyDelay(nil) +} + +func TestSleepForReadyDelay_ZeroDelay(t *testing.T) { + rc := &config.RuntimeConfig{ + Tmux: &config.RuntimeTmuxConfig{ + ReadyDelayMs: 0, + }, + } + + start := time.Now() + SleepForReadyDelay(rc) + elapsed := time.Since(start) + + // Should return immediately + if elapsed > 100*time.Millisecond { + t.Errorf("SleepForReadyDelay() with zero delay took too long: %v", elapsed) + } +} + +func TestSleepForReadyDelay_WithDelay(t *testing.T) { + rc := &config.RuntimeConfig{ + Tmux: &config.RuntimeTmuxConfig{ + ReadyDelayMs: 10, // 10ms delay + }, + } + + start := time.Now() + SleepForReadyDelay(rc) + elapsed := time.Since(start) + + // Should sleep for at least 10ms + if elapsed < 10*time.Millisecond { + t.Errorf("SleepForReadyDelay() should sleep for at least 10ms, took %v", elapsed) + } + // But not too long + if elapsed > 50*time.Millisecond { + t.Errorf("SleepForReadyDelay() slept too long: %v", elapsed) + } +} + +func TestSleepForReadyDelay_NilTmuxConfig(t *testing.T) { + rc := &config.RuntimeConfig{ + Tmux: nil, + } + + start := time.Now() + SleepForReadyDelay(rc) + elapsed := time.Since(start) + + // Should return immediately + if elapsed > 100*time.Millisecond { + t.Errorf("SleepForReadyDelay() with nil Tmux config took too long: %v", elapsed) + } +} + +func TestStartupFallbackCommands_NoHooks(t *testing.T) { + rc := &config.RuntimeConfig{ + Hooks: &config.RuntimeHooksConfig{ + Provider: "none", + }, + } + + commands := StartupFallbackCommands("polecat", rc) + if commands == nil { + t.Error("StartupFallbackCommands() with no hooks should return commands") + } + if len(commands) == 0 { + t.Error("StartupFallbackCommands() should return at least one command") + } +} + +func TestStartupFallbackCommands_WithHooks(t *testing.T) { + rc := &config.RuntimeConfig{ + Hooks: &config.RuntimeHooksConfig{ + Provider: "claude", + }, + } + + commands := StartupFallbackCommands("polecat", rc) + if commands != nil { + t.Error("StartupFallbackCommands() with hooks provider should return nil") + } +} + +func TestStartupFallbackCommands_NilConfig(t *testing.T) { + // Nil config defaults to claude provider, which has hooks + // So it returns nil (no fallback commands needed) + commands := StartupFallbackCommands("polecat", nil) + if commands != nil { + t.Error("StartupFallbackCommands() with nil config should return nil (defaults to claude with hooks)") + } +} + +func TestStartupFallbackCommands_AutonomousRole(t *testing.T) { + rc := &config.RuntimeConfig{ + Hooks: &config.RuntimeHooksConfig{ + Provider: "none", + }, + } + + autonomousRoles := []string{"polecat", "witness", "refinery", "deacon"} + for _, role := range autonomousRoles { + t.Run(role, func(t *testing.T) { + commands := StartupFallbackCommands(role, rc) + if commands == nil || len(commands) == 0 { + t.Error("StartupFallbackCommands() should return commands for autonomous role") + } + // Should contain mail check + found := false + for _, cmd := range commands { + if contains(cmd, "mail check --inject") { + found = true + break + } + } + if !found { + t.Errorf("Commands for %s should contain mail check --inject", role) + } + }) + } +} + +func TestStartupFallbackCommands_NonAutonomousRole(t *testing.T) { + rc := &config.RuntimeConfig{ + Hooks: &config.RuntimeHooksConfig{ + Provider: "none", + }, + } + + nonAutonomousRoles := []string{"mayor", "crew", "keeper"} + for _, role := range nonAutonomousRoles { + t.Run(role, func(t *testing.T) { + commands := StartupFallbackCommands(role, rc) + if commands == nil || len(commands) == 0 { + t.Error("StartupFallbackCommands() should return commands for non-autonomous role") + } + // Should NOT contain mail check + for _, cmd := range commands { + if contains(cmd, "mail check --inject") { + t.Errorf("Commands for %s should NOT contain mail check --inject", role) + } + } + }) + } +} + +func TestStartupFallbackCommands_RoleCasing(t *testing.T) { + rc := &config.RuntimeConfig{ + Hooks: &config.RuntimeHooksConfig{ + Provider: "none", + }, + } + + // Role should be lowercased internally + commands := StartupFallbackCommands("POLECAT", rc) + if commands == nil { + t.Error("StartupFallbackCommands() should handle uppercase role") + } +} + +func TestEnsureSettingsForRole_NilConfig(t *testing.T) { + // Should not panic with nil config + err := EnsureSettingsForRole("/tmp/test", "polecat", nil) + if err != nil { + t.Errorf("EnsureSettingsForRole() with nil config should not error, got %v", err) + } +} + +func TestEnsureSettingsForRole_NilHooks(t *testing.T) { + rc := &config.RuntimeConfig{ + Hooks: nil, + } + + err := EnsureSettingsForRole("/tmp/test", "polecat", rc) + if err != nil { + t.Errorf("EnsureSettingsForRole() with nil hooks should not error, got %v", err) + } +} + +func TestEnsureSettingsForRole_UnknownProvider(t *testing.T) { + rc := &config.RuntimeConfig{ + Hooks: &config.RuntimeHooksConfig{ + Provider: "unknown", + }, + } + + err := EnsureSettingsForRole("/tmp/test", "polecat", rc) + if err != nil { + t.Errorf("EnsureSettingsForRole() with unknown provider should not error, got %v", err) + } +} + +// Helper function +func contains(s, substr string) bool { + return len(s) >= len(substr) && findSubstring(s, substr) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + match := true + for j := 0; j < len(substr); j++ { + if s[i+j] != substr[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/internal/session/town_test.go b/internal/session/town_test.go new file mode 100644 index 00000000..87b08eb0 --- /dev/null +++ b/internal/session/town_test.go @@ -0,0 +1,93 @@ +package session + +import ( + "testing" +) + +func TestTownSessions(t *testing.T) { + sessions := TownSessions() + + if len(sessions) != 3 { + t.Errorf("TownSessions() returned %d sessions, want 3", len(sessions)) + } + + // Verify order is correct (Mayor, Boot, Deacon) + expectedOrder := []string{"Mayor", "Boot", "Deacon"} + for i, s := range sessions { + if s.Name != expectedOrder[i] { + t.Errorf("TownSessions()[%d].Name = %q, want %q", i, s.Name, expectedOrder[i]) + } + if s.SessionID == "" { + t.Errorf("TownSessions()[%d].SessionID should not be empty", i) + } + } +} + +func TestTownSessions_SessionIDFormats(t *testing.T) { + sessions := TownSessions() + + for _, s := range sessions { + if s.SessionID == "" { + t.Errorf("TownSession %q has empty SessionID", s.Name) + } + // Session IDs should follow a pattern + if len(s.SessionID) < 4 { + t.Errorf("TownSession %q SessionID %q is too short", s.Name, s.SessionID) + } + } +} + +func TestTownSession_StructFields(t *testing.T) { + ts := TownSession{ + Name: "Test", + SessionID: "test-session", + } + + if ts.Name != "Test" { + t.Errorf("TownSession.Name = %q, want %q", ts.Name, "Test") + } + if ts.SessionID != "test-session" { + t.Errorf("TownSession.SessionID = %q, want %q", ts.SessionID, "test-session") + } +} + +func TestTownSession_CanBeCreated(t *testing.T) { + // Test that TownSession can be created with any values + tests := []struct { + name string + sessionID string + }{ + {"Mayor", "hq-mayor"}, + {"Boot", "hq-boot"}, + {"Custom", "custom-session"}, + } + + for _, tt := range tests { + ts := TownSession{ + Name: tt.name, + SessionID: tt.sessionID, + } + if ts.Name != tt.name { + t.Errorf("TownSession.Name = %q, want %q", ts.Name, tt.name) + } + if ts.SessionID != tt.sessionID { + t.Errorf("TownSession.SessionID = %q, want %q", ts.SessionID, tt.sessionID) + } + } +} + +func TestTownSession_ShutdownOrder(t *testing.T) { + // Verify that shutdown order is Mayor -> Boot -> Deacon + // This is critical because Boot monitors Deacon + sessions := TownSessions() + + if sessions[0].Name != "Mayor" { + t.Errorf("First session should be Mayor, got %q", sessions[0].Name) + } + if sessions[1].Name != "Boot" { + t.Errorf("Second session should be Boot, got %q", sessions[1].Name) + } + if sessions[2].Name != "Deacon" { + t.Errorf("Third session should be Deacon, got %q", sessions[2].Name) + } +} diff --git a/internal/style/style_test.go b/internal/style/style_test.go new file mode 100644 index 00000000..48ce2680 --- /dev/null +++ b/internal/style/style_test.go @@ -0,0 +1,169 @@ +package style + +import ( + "bytes" + "fmt" + "io" + "os" + "testing" +) + +func TestStyleVariables(t *testing.T) { + // Test that all style variables render non-empty output + tests := []struct { + name string + render func(...string) string + }{ + {"Success", Success.Render}, + {"Warning", Warning.Render}, + {"Error", Error.Render}, + {"Info", Info.Render}, + {"Dim", Dim.Render}, + {"Bold", Bold.Render}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.render == nil { + t.Errorf("Style variable %s should not be nil", tt.name) + } + // Test that Render works + result := tt.render("test") + if result == "" { + t.Errorf("Style %s.Render() should not return empty string", tt.name) + } + }) + } +} + +func TestPrefixVariables(t *testing.T) { + // Test that all prefix variables are non-empty + tests := []struct { + name string + prefix string + }{ + {"SuccessPrefix", SuccessPrefix}, + {"WarningPrefix", WarningPrefix}, + {"ErrorPrefix", ErrorPrefix}, + {"ArrowPrefix", ArrowPrefix}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prefix == "" { + t.Errorf("Prefix variable %s should not be empty", tt.name) + } + }) + } +} + +func TestPrintWarning(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + PrintWarning("test warning: %s", "value") + + w.Close() + os.Stdout = oldStdout + + // Read captured output + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + if output == "" { + t.Error("PrintWarning() should produce output") + } + + // Check that warning message is present + if !bytes.Contains(buf.Bytes(), []byte("test warning: value")) { + t.Error("PrintWarning() output should contain the warning message") + } +} + +func TestPrintWarning_NoFormatArgs(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + PrintWarning("simple warning") + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + if output == "" { + t.Error("PrintWarning() should produce output") + } + + if !bytes.Contains(buf.Bytes(), []byte("simple warning")) { + t.Error("PrintWarning() output should contain the message") + } +} + +func TestStyles_RenderConsistently(t *testing.T) { + // Test that styles consistently render non-empty output + testText := "test message" + + styles := map[string]func(...string) string{ + "Success": Success.Render, + "Warning": Warning.Render, + "Error": Error.Render, + "Info": Info.Render, + "Dim": Dim.Render, + "Bold": Bold.Render, + } + + for name, renderFunc := range styles { + t.Run(name, func(t *testing.T) { + result := renderFunc(testText) + if result == "" { + t.Errorf("Style %s.Render() should not return empty string", name) + } + // Result should be different from input (has styling codes) + // except possibly for some edge cases + }) + } +} + +func TestMultiplePrintWarning(t *testing.T) { + // Test that multiple warnings can be printed + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + for i := 0; i < 3; i++ { + PrintWarning("warning %d", i) + } + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + _ = buf.String() // ensure buffer is read + + // Should have 3 lines + lineCount := 0 + for _, b := range buf.Bytes() { + if b == '\n' { + lineCount++ + } + } + + if lineCount != 3 { + t.Errorf("Expected 3 lines of output, got %d", lineCount) + } +} + +func ExamplePrintWarning() { + // This example demonstrates PrintWarning usage + fmt.Print("Example output:\n") + PrintWarning("This is a warning message") + PrintWarning("Warning with value: %d", 42) +} diff --git a/internal/ui/markdown_test.go b/internal/ui/markdown_test.go new file mode 100644 index 00000000..f5e3e8da --- /dev/null +++ b/internal/ui/markdown_test.go @@ -0,0 +1,234 @@ +package ui + +import ( + "os" + "strings" + "testing" +) + +func TestRenderMarkdown_AgentMode(t *testing.T) { + oldAgentMode := os.Getenv("GT_AGENT_MODE") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + }() + + os.Setenv("GT_AGENT_MODE", "1") + + markdown := "# Test Header\n\nSome content" + result := RenderMarkdown(markdown) + + if result != markdown { + t.Errorf("RenderMarkdown() in agent mode should return raw markdown, got %q", result) + } +} + +func TestRenderMarkdown_SimpleText(t *testing.T) { + oldAgentMode := os.Getenv("GT_AGENT_MODE") + oldNoColor := os.Getenv("NO_COLOR") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + }() + + os.Unsetenv("GT_AGENT_MODE") + os.Setenv("NO_COLOR", "1") // Disable glamour rendering + + markdown := "Simple text without formatting" + result := RenderMarkdown(markdown) + + // When color is disabled, should return raw markdown + if result != markdown { + t.Errorf("RenderMarkdown() with color disabled should return raw markdown, got %q", result) + } +} + +func TestRenderMarkdown_EmptyString(t *testing.T) { + oldAgentMode := os.Getenv("GT_AGENT_MODE") + oldNoColor := os.Getenv("NO_COLOR") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + }() + + os.Unsetenv("GT_AGENT_MODE") + os.Setenv("NO_COLOR", "1") + + result := RenderMarkdown("") + if result != "" { + t.Errorf("RenderMarkdown() with empty string should return empty, got %q", result) + } +} + +func TestRenderMarkdown_GracefulDegradation(t *testing.T) { + oldAgentMode := os.Getenv("GT_AGENT_MODE") + oldNoColor := os.Getenv("NO_COLOR") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + }() + + os.Unsetenv("GT_AGENT_MODE") + os.Setenv("NO_COLOR", "1") + + // Test that function doesn't panic and always returns something + markdown := "# Test\n\nContent with **bold** and *italic*" + result := RenderMarkdown(markdown) + + if result == "" { + t.Error("RenderMarkdown() should never return empty string for non-empty input") + } + // With NO_COLOR, should return raw markdown + if !strings.Contains(result, "bold") { + t.Error("RenderMarkdown() should contain original content") + } +} + +func TestGetTerminalWidth(t *testing.T) { + // This function is unexported, but we can test it indirectly + // The function should return a reasonable width + // Since we can't call it directly, we verify RenderMarkdown doesn't panic + + oldAgentMode := os.Getenv("GT_AGENT_MODE") + oldNoColor := os.Getenv("NO_COLOR") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + }() + + os.Unsetenv("GT_AGENT_MODE") + os.Setenv("NO_COLOR", "1") + + // This should not panic even if terminal width detection fails + markdown := strings.Repeat("word ", 1000) // Long content + result := RenderMarkdown(markdown) + + if result == "" { + t.Error("RenderMarkdown() should handle long content") + } +} + +func TestRenderMarkdown_Newlines(t *testing.T) { + oldAgentMode := os.Getenv("GT_AGENT_MODE") + oldNoColor := os.Getenv("NO_COLOR") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + }() + + os.Unsetenv("GT_AGENT_MODE") + os.Setenv("NO_COLOR", "1") + + markdown := "Line 1\n\nLine 2\n\nLine 3" + result := RenderMarkdown(markdown) + + // With color disabled, newlines should be preserved + if !strings.Contains(result, "Line 1") { + t.Error("RenderMarkdown() should preserve first line") + } + if !strings.Contains(result, "Line 2") { + t.Error("RenderMarkdown() should preserve second line") + } + if !strings.Contains(result, "Line 3") { + t.Error("RenderMarkdown() should preserve third line") + } +} + +func TestRenderMarkdown_CodeBlocks(t *testing.T) { + oldAgentMode := os.Getenv("GT_AGENT_MODE") + oldNoColor := os.Getenv("NO_COLOR") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + }() + + os.Unsetenv("GT_AGENT_MODE") + os.Setenv("NO_COLOR", "1") + + markdown := "```go\nfunc main() {}\n```" + result := RenderMarkdown(markdown) + + // Should contain the code + if !strings.Contains(result, "func main") { + t.Error("RenderMarkdown() should preserve code block content") + } +} + +func TestRenderMarkdown_Links(t *testing.T) { + oldAgentMode := os.Getenv("GT_AGENT_MODE") + oldNoColor := os.Getenv("NO_COLOR") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + }() + + os.Unsetenv("GT_AGENT_MODE") + os.Setenv("NO_COLOR", "1") + + markdown := "[link text](https://example.com)" + result := RenderMarkdown(markdown) + + // Should contain the link text or URL + if !strings.Contains(result, "link text") && !strings.Contains(result, "example.com") { + t.Error("RenderMarkdown() should preserve link information") + } +} diff --git a/internal/ui/terminal_test.go b/internal/ui/terminal_test.go new file mode 100644 index 00000000..a309b9c6 --- /dev/null +++ b/internal/ui/terminal_test.go @@ -0,0 +1,247 @@ +package ui + +import ( + "os" + "testing" +) + +func TestIsTerminal(t *testing.T) { + // This test verifies the function doesn't panic + // The actual result depends on the test environment + result := IsTerminal() + // In test environment, this is usually false + // The important thing is it doesn't crash and returns a bool + var _ bool = result +} + +func TestShouldUseColor_Default(t *testing.T) { + // Clean environment for this test + oldNoColor := os.Getenv("NO_COLOR") + oldClicolor := os.Getenv("CLICOLOR") + oldClicolorForce := os.Getenv("CLICOLOR_FORCE") + defer func() { + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + if oldClicolor != "" { + os.Setenv("CLICOLOR", oldClicolor) + } else { + os.Unsetenv("CLICOLOR") + } + if oldClicolorForce != "" { + os.Setenv("CLICOLOR_FORCE", oldClicolorForce) + } else { + os.Unsetenv("CLICOLOR_FORCE") + } + }() + + os.Unsetenv("NO_COLOR") + os.Unsetenv("CLICOLOR") + os.Unsetenv("CLICOLOR_FORCE") + + result := ShouldUseColor() + // In non-TTY test environment, should be false + _ = result +} + +func TestShouldUseColor_NO_COLOR(t *testing.T) { + oldNoColor := os.Getenv("NO_COLOR") + defer func() { + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + }() + + os.Setenv("NO_COLOR", "1") + if ShouldUseColor() { + t.Error("ShouldUseColor() should return false when NO_COLOR is set") + } +} + +func TestShouldUseColor_NO_COLOR_AnyValue(t *testing.T) { + oldNoColor := os.Getenv("NO_COLOR") + defer func() { + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + }() + + // NO_COLOR with any value (even "0") should disable color + os.Setenv("NO_COLOR", "0") + if ShouldUseColor() { + t.Error("ShouldUseColor() should return false when NO_COLOR is set to any value") + } +} + +func TestShouldUseColor_CLICOLOR_0(t *testing.T) { + oldNoColor := os.Getenv("NO_COLOR") + oldClicolor := os.Getenv("CLICOLOR") + defer func() { + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + if oldClicolor != "" { + os.Setenv("CLICOLOR", oldClicolor) + } else { + os.Unsetenv("CLICOLOR") + } + }() + + os.Unsetenv("NO_COLOR") + os.Setenv("CLICOLOR", "0") + if ShouldUseColor() { + t.Error("ShouldUseColor() should return false when CLICOLOR=0") + } +} + +func TestShouldUseColor_CLICOLOR_FORCE(t *testing.T) { + oldNoColor := os.Getenv("NO_COLOR") + oldClicolorForce := os.Getenv("CLICOLOR_FORCE") + defer func() { + if oldNoColor != "" { + os.Setenv("NO_COLOR", oldNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + if oldClicolorForce != "" { + os.Setenv("CLICOLOR_FORCE", oldClicolorForce) + } else { + os.Unsetenv("CLICOLOR_FORCE") + } + }() + + os.Unsetenv("NO_COLOR") + os.Setenv("CLICOLOR_FORCE", "1") + if !ShouldUseColor() { + t.Error("ShouldUseColor() should return true when CLICOLOR_FORCE is set") + } +} + +func TestShouldUseEmoji_Default(t *testing.T) { + oldNoEmoji := os.Getenv("GT_NO_EMOJI") + defer func() { + if oldNoEmoji != "" { + os.Setenv("GT_NO_EMOJI", oldNoEmoji) + } else { + os.Unsetenv("GT_NO_EMOJI") + } + }() + + os.Unsetenv("GT_NO_EMOJI") + result := ShouldUseEmoji() + _ = result // Result depends on test environment +} + +func TestShouldUseEmoji_GT_NO_EMOJI(t *testing.T) { + oldNoEmoji := os.Getenv("GT_NO_EMOJI") + defer func() { + if oldNoEmoji != "" { + os.Setenv("GT_NO_EMOJI", oldNoEmoji) + } else { + os.Unsetenv("GT_NO_EMOJI") + } + }() + + os.Setenv("GT_NO_EMOJI", "1") + if ShouldUseEmoji() { + t.Error("ShouldUseEmoji() should return false when GT_NO_EMOJI is set") + } +} + +func TestIsAgentMode_Default(t *testing.T) { + oldAgentMode := os.Getenv("GT_AGENT_MODE") + oldClaudeCode := os.Getenv("CLAUDE_CODE") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + if oldClaudeCode != "" { + os.Setenv("CLAUDE_CODE", oldClaudeCode) + } else { + os.Unsetenv("CLAUDE_CODE") + } + }() + + os.Unsetenv("GT_AGENT_MODE") + os.Unsetenv("CLAUDE_CODE") + if IsAgentMode() { + t.Error("IsAgentMode() should return false by default") + } +} + +func TestIsAgentMode_GT_AGENT_MODE(t *testing.T) { + oldAgentMode := os.Getenv("GT_AGENT_MODE") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + }() + + os.Setenv("GT_AGENT_MODE", "1") + if !IsAgentMode() { + t.Error("IsAgentMode() should return true when GT_AGENT_MODE=1") + } + + os.Setenv("GT_AGENT_MODE", "0") + if IsAgentMode() { + t.Error("IsAgentMode() should return false when GT_AGENT_MODE=0") + } +} + +func TestIsAgentMode_CLAUDE_CODE(t *testing.T) { + oldAgentMode := os.Getenv("GT_AGENT_MODE") + oldClaudeCode := os.Getenv("CLAUDE_CODE") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + if oldClaudeCode != "" { + os.Setenv("CLAUDE_CODE", oldClaudeCode) + } else { + os.Unsetenv("CLAUDE_CODE") + } + }() + + os.Unsetenv("GT_AGENT_MODE") + os.Setenv("CLAUDE_CODE", "1") + if !IsAgentMode() { + t.Error("IsAgentMode() should return true when CLAUDE_CODE is set") + } +} + +func TestIsAgentMode_CLAUDE_CODE_AnyValue(t *testing.T) { + oldAgentMode := os.Getenv("GT_AGENT_MODE") + oldClaudeCode := os.Getenv("CLAUDE_CODE") + defer func() { + if oldAgentMode != "" { + os.Setenv("GT_AGENT_MODE", oldAgentMode) + } else { + os.Unsetenv("GT_AGENT_MODE") + } + if oldClaudeCode != "" { + os.Setenv("CLAUDE_CODE", oldClaudeCode) + } else { + os.Unsetenv("CLAUDE_CODE") + } + }() + + os.Unsetenv("GT_AGENT_MODE") + os.Setenv("CLAUDE_CODE", "any-value") + if !IsAgentMode() { + t.Error("IsAgentMode() should return true when CLAUDE_CODE is set to any value") + } +} diff --git a/internal/wisp/io_test.go b/internal/wisp/io_test.go new file mode 100644 index 00000000..a0299d8b --- /dev/null +++ b/internal/wisp/io_test.go @@ -0,0 +1,196 @@ +package wisp + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestEnsureDir(t *testing.T) { + tmpDir := t.TempDir() + + // Test creating directory in existing root + dir, err := EnsureDir(tmpDir) + if err != nil { + t.Fatalf("EnsureDir() error = %v", err) + } + + expectedDir := filepath.Join(tmpDir, WispDir) + if dir != expectedDir { + t.Errorf("EnsureDir() = %q, want %q", dir, expectedDir) + } + + // Verify directory exists + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("Failed to stat directory: %v", err) + } + if !info.IsDir() { + t.Error("EnsureDir() should create a directory") + } + + // Test calling again (should be idempotent) + dir2, err := EnsureDir(tmpDir) + if err != nil { + t.Fatalf("EnsureDir() second call error = %v", err) + } + if dir2 != dir { + t.Errorf("EnsureDir() second call = %q, want %q", dir2, dir) + } +} + +func TestEnsureDir_Permissions(t *testing.T) { + tmpDir := t.TempDir() + + dir, err := EnsureDir(tmpDir) + if err != nil { + t.Fatalf("EnsureDir() error = %v", err) + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("Failed to stat directory: %v", err) + } + + // Check directory permissions are 0755 + expectedPerm := os.FileMode(0755) + if info.Mode().Perm() != expectedPerm { + t.Errorf("Directory permissions = %v, want %v", info.Mode().Perm(), expectedPerm) + } +} + +func TestWispPath(t *testing.T) { + tests := []struct { + name string + root string + filename string + want string + }{ + { + name: "basic path", + root: "/path/to/root", + filename: "bead.json", + want: "/path/to/root/.beads/bead.json", + }, + { + name: "nested filename", + root: "/path/to/root", + filename: "subdir/bead.json", + want: "/path/to/root/.beads/subdir/bead.json", + }, + { + name: "empty filename", + root: "/path/to/root", + filename: "", + want: "/path/to/root/.beads", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := WispPath(tt.root, tt.filename) + if got != tt.want { + t.Errorf("WispPath() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestWispPath_WithWispDir(t *testing.T) { + // Verify WispPath uses WispDir constant + root := "/test/root" + filename := "test.json" + + expected := filepath.Join(root, WispDir, filename) + got := WispPath(root, filename) + + if got != expected { + t.Errorf("WispPath() = %q, want %q (using WispDir=%q)", got, expected, WispDir) + } +} + +func TestWriteJSON_Helper(t *testing.T) { + tmpDir := t.TempDir() + testPath := filepath.Join(tmpDir, "test.json") + + type TestData struct { + Name string `json:"name"` + Value int `json:"value"` + } + + data := TestData{ + Name: "test", + Value: 42, + } + + // writeJSON is unexported, so we test it indirectly through other functions + // or we can test the behavior through EnsureDir which uses similar patterns + + // For now, test that we can write JSON using AtomicWriteJSON from util package + // which is what wisp would typically use + err := writeJSON(testPath, data) + if err != nil { + t.Fatalf("writeJSON() error = %v", err) + } + + // Verify file exists + content, err := os.ReadFile(testPath) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + // Verify JSON is valid + var decoded TestData + if err := json.Unmarshal(content, &decoded); err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if decoded.Name != data.Name { + t.Errorf("Name = %q, want %q", decoded.Name, data.Name) + } + if decoded.Value != data.Value { + t.Errorf("Value = %d, want %d", decoded.Value, data.Value) + } + + // Verify temp file was cleaned up + tmpPath := testPath + ".tmp" + if _, err := os.Stat(tmpPath); err == nil { + t.Error("Temp file should be removed after successful write") + } +} + +func TestWriteJSON_Overwrite(t *testing.T) { + tmpDir := t.TempDir() + testPath := filepath.Join(tmpDir, "test.json") + + // Write initial data + initialData := map[string]string{"key": "initial"} + if err := writeJSON(testPath, initialData); err != nil { + t.Fatalf("writeJSON() initial error = %v", err) + } + + // Overwrite with new data + newData := map[string]string{"key": "updated", "new": "value"} + if err := writeJSON(testPath, newData); err != nil { + t.Fatalf("writeJSON() overwrite error = %v", err) + } + + // Verify data was updated + content, err := os.ReadFile(testPath) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + var decoded map[string]string + if err := json.Unmarshal(content, &decoded); err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if decoded["key"] != "updated" { + t.Errorf("key = %q, want %q", decoded["key"], "updated") + } + if decoded["new"] != "value" { + t.Errorf("new = %q, want %q", decoded["new"], "value") + } +} diff --git a/internal/wisp/types_test.go b/internal/wisp/types_test.go new file mode 100644 index 00000000..6e0a3647 --- /dev/null +++ b/internal/wisp/types_test.go @@ -0,0 +1,25 @@ +package wisp + +import "testing" + +func TestWispDir(t *testing.T) { + // Test that WispDir constant is defined correctly + expected := ".beads" + if WispDir != expected { + t.Errorf("WispDir = %q, want %q", WispDir, expected) + } +} + +func TestWispDirNotEmpty(t *testing.T) { + // Test that WispDir is not empty + if WispDir == "" { + t.Error("WispDir should not be empty") + } +} + +func TestWispDirStartsWithDot(t *testing.T) { + // Test that WispDir is a hidden directory (starts with dot) + if len(WispDir) == 0 || WispDir[0] != '.' { + t.Errorf("WispDir should start with '.' for hidden directory, got %q", WispDir) + } +} diff --git a/internal/witness/types_test.go b/internal/witness/types_test.go new file mode 100644 index 00000000..12fbdd97 --- /dev/null +++ b/internal/witness/types_test.go @@ -0,0 +1,230 @@ +package witness + +import ( + "encoding/json" + "testing" + "time" + + "github.com/steveyegge/gastown/internal/agent" +) + +func TestStateTypeAlias(t *testing.T) { + // Verify State is an alias for agent.State + var s State = agent.StateRunning + if s != agent.StateRunning { + t.Errorf("State type alias not working correctly") + } +} + +func TestStateConstants(t *testing.T) { + tests := []struct { + name string + state State + parent agent.State + }{ + {"StateStopped", StateStopped, agent.StateStopped}, + {"StateRunning", StateRunning, agent.StateRunning}, + {"StatePaused", StatePaused, agent.StatePaused}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.state != tt.parent { + t.Errorf("State constant %s = %v, want %v", tt.name, tt.state, tt.parent) + } + }) + } +} + +func TestWitness_ZeroValues(t *testing.T) { + var w Witness + + if w.RigName != "" { + t.Errorf("zero value Witness.RigName should be empty, got %q", w.RigName) + } + if w.State != "" { + t.Errorf("zero value Witness.State should be empty, got %q", w.State) + } + if w.PID != 0 { + t.Errorf("zero value Witness.PID should be 0, got %d", w.PID) + } + if w.StartedAt != nil { + t.Error("zero value Witness.StartedAt should be nil") + } +} + +func TestWitness_JSONMarshaling(t *testing.T) { + now := time.Now().Round(time.Second) + w := Witness{ + RigName: "gastown", + State: StateRunning, + PID: 12345, + StartedAt: &now, + MonitoredPolecats: []string{"keeper", "valkyrie"}, + Config: WitnessConfig{ + MaxWorkers: 4, + SpawnDelayMs: 5000, + AutoSpawn: true, + }, + SpawnedIssues: []string{"hq-abc123"}, + } + + data, err := json.Marshal(w) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var unmarshaled Witness + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if unmarshaled.RigName != w.RigName { + t.Errorf("After round-trip: RigName = %q, want %q", unmarshaled.RigName, w.RigName) + } + if unmarshaled.State != w.State { + t.Errorf("After round-trip: State = %v, want %v", unmarshaled.State, w.State) + } + if unmarshaled.PID != w.PID { + t.Errorf("After round-trip: PID = %d, want %d", unmarshaled.PID, w.PID) + } +} + +func TestWitnessConfig_ZeroValues(t *testing.T) { + var cfg WitnessConfig + + if cfg.MaxWorkers != 0 { + t.Errorf("zero value WitnessConfig.MaxWorkers should be 0, got %d", cfg.MaxWorkers) + } + if cfg.SpawnDelayMs != 0 { + t.Errorf("zero value WitnessConfig.SpawnDelayMs should be 0, got %d", cfg.SpawnDelayMs) + } + if cfg.AutoSpawn { + t.Error("zero value WitnessConfig.AutoSpawn should be false") + } +} + +func TestWitnessConfig_JSONMarshaling(t *testing.T) { + cfg := WitnessConfig{ + MaxWorkers: 8, + SpawnDelayMs: 10000, + AutoSpawn: false, + EpicID: "epic-123", + IssuePrefix: "gt-", + } + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var unmarshaled WitnessConfig + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if unmarshaled.MaxWorkers != cfg.MaxWorkers { + t.Errorf("After round-trip: MaxWorkers = %d, want %d", unmarshaled.MaxWorkers, cfg.MaxWorkers) + } + if unmarshaled.SpawnDelayMs != cfg.SpawnDelayMs { + t.Errorf("After round-trip: SpawnDelayMs = %d, want %d", unmarshaled.SpawnDelayMs, cfg.SpawnDelayMs) + } + if unmarshaled.AutoSpawn != cfg.AutoSpawn { + t.Errorf("After round-trip: AutoSpawn = %v, want %v", unmarshaled.AutoSpawn, cfg.AutoSpawn) + } + if unmarshaled.EpicID != cfg.EpicID { + t.Errorf("After round-trip: EpicID = %q, want %q", unmarshaled.EpicID, cfg.EpicID) + } + if unmarshaled.IssuePrefix != cfg.IssuePrefix { + t.Errorf("After round-trip: IssuePrefix = %q, want %q", unmarshaled.IssuePrefix, cfg.IssuePrefix) + } +} + +func TestWitnessConfig_OmitEmpty(t *testing.T) { + cfg := WitnessConfig{ + MaxWorkers: 4, + SpawnDelayMs: 5000, + AutoSpawn: true, + // EpicID and IssuePrefix left empty + } + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("json.Unmarshal() to map error = %v", err) + } + + // Empty fields should be omitted + if _, exists := raw["epic_id"]; exists { + t.Error("Field 'epic_id' should be omitted when empty") + } + if _, exists := raw["issue_prefix"]; exists { + t.Error("Field 'issue_prefix' should be omitted when empty") + } + + // Required fields should be present + requiredFields := []string{"max_workers", "spawn_delay_ms", "auto_spawn"} + for _, field := range requiredFields { + if _, exists := raw[field]; !exists { + t.Errorf("Required field '%s' should be present", field) + } + } +} + +func TestWitness_OmitEmpty(t *testing.T) { + w := Witness{ + RigName: "gastown", + State: StateRunning, + // PID, StartedAt, MonitoredPolecats, SpawnedIssues left empty/nil + } + + data, err := json.Marshal(w) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("json.Unmarshal() to map error = %v", err) + } + + // Empty optional fields should be omitted + if _, exists := raw["pid"]; exists { + t.Error("Field 'pid' should be omitted when zero") + } + if _, exists := raw["started_at"]; exists { + t.Error("Field 'started_at' should be omitted when nil") + } + if _, exists := raw["monitored_polecats"]; exists { + t.Error("Field 'monitored_polecats' should be omitted when nil/empty") + } + if _, exists := raw["spawned_issues"]; exists { + t.Error("Field 'spawned_issues' should be omitted when nil/empty") + } +} + +func TestWitness_WithMonitoredPolecats(t *testing.T) { + w := Witness{ + RigName: "gastown", + State: StateRunning, + MonitoredPolecats: []string{"keeper", "valkyrie", "nux"}, + } + + data, err := json.Marshal(w) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var unmarshaled Witness + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if len(unmarshaled.MonitoredPolecats) != 3 { + t.Errorf("After round-trip: MonitoredPolecats length = %d, want 3", len(unmarshaled.MonitoredPolecats)) + } +}