Files
gastown/internal/polecat/manager_test.go
dementus a2607b5b72 fix(tests): resolve test failures in costs and polecat tests
1. TestQuerySessionEvents_FindsEventsFromAllLocations
   - Skip test when running inside Gas Town workspace to prevent
     daemon interaction causing hangs
   - Add filterGTEnv helper to isolate subprocess environment

2. TestAddWithOptions_HasAgentsMD / TestAddWithOptions_AgentsMDFallback
   - Create origin/main ref manually after adding local directory as
     remote since git fetch doesn't create tracking branches for local
     directories

Refs: gt-zbu3x

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 12:07:50 -08:00

443 lines
12 KiB
Go

package polecat
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
)
func TestStateIsActive(t *testing.T) {
tests := []struct {
state State
active bool
}{
{StateWorking, true},
{StateDone, false},
{StateStuck, false},
// Legacy active state is treated as active
{StateActive, true},
}
for _, tt := range tests {
if got := tt.state.IsActive(); got != tt.active {
t.Errorf("%s.IsActive() = %v, want %v", tt.state, got, tt.active)
}
}
}
func TestStateIsWorking(t *testing.T) {
tests := []struct {
state State
working bool
}{
{StateActive, false},
{StateWorking, true},
{StateDone, false},
{StateStuck, false},
}
for _, tt := range tests {
if got := tt.state.IsWorking(); got != tt.working {
t.Errorf("%s.IsWorking() = %v, want %v", tt.state, got, tt.working)
}
}
}
func TestPolecatSummary(t *testing.T) {
p := &Polecat{
Name: "Toast",
State: StateWorking,
Issue: "gt-abc",
}
summary := p.Summary()
if summary.Name != "Toast" {
t.Errorf("Name = %q, want Toast", summary.Name)
}
if summary.State != StateWorking {
t.Errorf("State = %v, want StateWorking", summary.State)
}
if summary.Issue != "gt-abc" {
t.Errorf("Issue = %q, want gt-abc", summary.Issue)
}
}
func TestListEmpty(t *testing.T) {
root := t.TempDir()
r := &rig.Rig{
Name: "test-rig",
Path: root,
}
m := NewManager(r, git.NewGit(root))
polecats, err := m.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(polecats) != 0 {
t.Errorf("polecats count = %d, want 0", len(polecats))
}
}
func TestGetNotFound(t *testing.T) {
root := t.TempDir()
r := &rig.Rig{
Name: "test-rig",
Path: root,
}
m := NewManager(r, git.NewGit(root))
_, err := m.Get("nonexistent")
if err != ErrPolecatNotFound {
t.Errorf("Get = %v, want ErrPolecatNotFound", err)
}
}
func TestRemoveNotFound(t *testing.T) {
root := t.TempDir()
r := &rig.Rig{
Name: "test-rig",
Path: root,
}
m := NewManager(r, git.NewGit(root))
err := m.Remove("nonexistent", false)
if err != ErrPolecatNotFound {
t.Errorf("Remove = %v, want ErrPolecatNotFound", err)
}
}
func TestPolecatDir(t *testing.T) {
r := &rig.Rig{
Name: "test-rig",
Path: "/home/user/ai/test-rig",
}
m := NewManager(r, git.NewGit(r.Path))
dir := m.polecatDir("Toast")
expected := "/home/user/ai/test-rig/polecats/Toast"
if dir != expected {
t.Errorf("polecatDir = %q, want %q", dir, expected)
}
}
func TestAssigneeID(t *testing.T) {
r := &rig.Rig{
Name: "test-rig",
Path: "/home/user/ai/test-rig",
}
m := NewManager(r, git.NewGit(r.Path))
id := m.assigneeID("Toast")
expected := "test-rig/Toast"
if id != expected {
t.Errorf("assigneeID = %q, want %q", id, expected)
}
}
// Note: State persistence tests removed - state is now derived from beads assignee field.
// Integration tests should verify beads-based state management.
func TestGetReturnsWorkingWithoutBeads(t *testing.T) {
// When beads is not available, Get should return StateWorking
// (assume the polecat is doing something if it exists)
//
// Skip if bd is installed - the test assumes bd is unavailable, but when bd
// is present it queries beads and returns actual state instead of defaulting.
if _, err := exec.LookPath("bd"); err == nil {
t.Skip("skipping: bd is installed, test requires bd to be unavailable")
}
root := t.TempDir()
polecatDir := filepath.Join(root, "polecats", "Test")
if err := os.MkdirAll(polecatDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Create mayor/rig directory for beads (but no actual beads)
mayorRigDir := filepath.Join(root, "mayor", "rig")
if err := os.MkdirAll(mayorRigDir, 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
r := &rig.Rig{
Name: "test-rig",
Path: root,
}
m := NewManager(r, git.NewGit(root))
// Get should return polecat with StateWorking (assume active if beads unavailable)
polecat, err := m.Get("Test")
if err != nil {
t.Fatalf("Get: %v", err)
}
if polecat.Name != "Test" {
t.Errorf("Name = %q, want Test", polecat.Name)
}
if polecat.State != StateWorking {
t.Errorf("State = %v, want StateWorking (beads not available)", polecat.State)
}
}
func TestListWithPolecats(t *testing.T) {
root := t.TempDir()
// Create some polecat directories (state is now derived from beads, not state files)
for _, name := range []string{"Toast", "Cheedo"} {
polecatDir := filepath.Join(root, "polecats", name)
if err := os.MkdirAll(polecatDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
}
if err := os.MkdirAll(filepath.Join(root, "polecats", ".claude"), 0755); err != nil {
t.Fatalf("mkdir .claude: %v", err)
}
// Create mayor/rig for beads path
mayorRig := filepath.Join(root, "mayor", "rig")
if err := os.MkdirAll(mayorRig, 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
r := &rig.Rig{
Name: "test-rig",
Path: root,
}
m := NewManager(r, git.NewGit(root))
polecats, err := m.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(polecats) != 2 {
t.Errorf("polecats count = %d, want 2", len(polecats))
}
}
// Note: TestSetState, TestAssignIssue, and TestClearIssue were removed.
// These operations now require a running beads instance and are tested
// via integration tests. The unit tests here focus on testing the basic
// polecat lifecycle operations that don't require beads.
func TestSetStateWithoutBeads(t *testing.T) {
// SetState should not error when beads is not available
root := t.TempDir()
polecatDir := filepath.Join(root, "polecats", "Test")
if err := os.MkdirAll(polecatDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Create mayor/rig for beads path
mayorRig := filepath.Join(root, "mayor", "rig")
if err := os.MkdirAll(mayorRig, 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
r := &rig.Rig{
Name: "test-rig",
Path: root,
}
m := NewManager(r, git.NewGit(root))
// SetState should succeed (no-op when no issue assigned)
err := m.SetState("Test", StateActive)
if err != nil {
t.Errorf("SetState: %v (expected no error when no beads/issue)", err)
}
}
func TestClearIssueWithoutAssignment(t *testing.T) {
// ClearIssue should not error when no issue is assigned
root := t.TempDir()
polecatDir := filepath.Join(root, "polecats", "Test")
if err := os.MkdirAll(polecatDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Create mayor/rig for beads path
mayorRig := filepath.Join(root, "mayor", "rig")
if err := os.MkdirAll(mayorRig, 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
r := &rig.Rig{
Name: "test-rig",
Path: root,
}
m := NewManager(r, git.NewGit(root))
// ClearIssue should succeed even when no issue assigned
err := m.ClearIssue("Test")
if err != nil {
t.Errorf("ClearIssue: %v (expected no error when no assignment)", err)
}
}
// NOTE: TestInstallCLAUDETemplate tests were removed.
// We no longer write CLAUDE.md to worktrees - Gas Town context is injected
// ephemerally via SessionStart hook (gt prime) to prevent leaking internal
// architecture into project repos.
func TestAddWithOptions_HasAgentsMD(t *testing.T) {
// This test verifies that AGENTS.md exists in polecat worktrees after creation.
// AGENTS.md is critical for polecats to "land the plane" properly.
root := t.TempDir()
// Create mayor/rig directory structure (this acts as repo base when no .repo.git)
mayorRig := filepath.Join(root, "mayor", "rig")
if err := os.MkdirAll(mayorRig, 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
// Initialize git repo in mayor/rig
cmd := exec.Command("git", "init")
cmd.Dir = mayorRig
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
// Create AGENTS.md with test content
agentsMDContent := []byte("# AGENTS.md\n\nTest content for polecats.\n")
agentsMDPath := filepath.Join(mayorRig, "AGENTS.md")
if err := os.WriteFile(agentsMDPath, agentsMDContent, 0644); err != nil {
t.Fatalf("write AGENTS.md: %v", err)
}
// Commit AGENTS.md so it's part of the repo
mayorGit := git.NewGit(mayorRig)
if err := mayorGit.Add("AGENTS.md"); err != nil {
t.Fatalf("git add: %v", err)
}
if err := mayorGit.Commit("Add AGENTS.md"); err != nil {
t.Fatalf("git commit: %v", err)
}
// AddWithOptions needs origin/main to exist. Add self as origin and create tracking ref.
cmd = exec.Command("git", "remote", "add", "origin", mayorRig)
cmd.Dir = mayorRig
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git remote add: %v\n%s", err, out)
}
// When using a local directory as remote, fetch doesn't create tracking branches.
// Create origin/main manually since AddWithOptions expects origin/main by default.
cmd = exec.Command("git", "update-ref", "refs/remotes/origin/main", "HEAD")
cmd.Dir = mayorRig
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git update-ref: %v\n%s", err, out)
}
// Create rig pointing to root
r := &rig.Rig{
Name: "rig",
Path: root,
}
m := NewManager(r, git.NewGit(root))
// Create polecat via AddWithOptions
polecat, err := m.AddWithOptions("TestAgent", AddOptions{})
if err != nil {
t.Fatalf("AddWithOptions: %v", err)
}
// Verify AGENTS.md exists in the worktree
worktreeAgentsMD := filepath.Join(polecat.ClonePath, "AGENTS.md")
if _, err := os.Stat(worktreeAgentsMD); os.IsNotExist(err) {
t.Errorf("AGENTS.md does not exist in worktree at %s", worktreeAgentsMD)
}
// Verify content matches
content, err := os.ReadFile(worktreeAgentsMD)
if err != nil {
t.Fatalf("read worktree AGENTS.md: %v", err)
}
if string(content) != string(agentsMDContent) {
t.Errorf("AGENTS.md content = %q, want %q", string(content), string(agentsMDContent))
}
}
func TestAddWithOptions_AgentsMDFallback(t *testing.T) {
// This test verifies the fallback: if AGENTS.md is not in git,
// it should be copied from mayor/rig.
root := t.TempDir()
// Create mayor/rig directory structure
mayorRig := filepath.Join(root, "mayor", "rig")
if err := os.MkdirAll(mayorRig, 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
// Initialize git repo in mayor/rig WITHOUT AGENTS.md in git
cmd := exec.Command("git", "init")
cmd.Dir = mayorRig
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
// Create a dummy file and commit (repo needs at least one commit)
dummyPath := filepath.Join(mayorRig, "README.md")
if err := os.WriteFile(dummyPath, []byte("# Test\n"), 0644); err != nil {
t.Fatalf("write README.md: %v", err)
}
mayorGit := git.NewGit(mayorRig)
if err := mayorGit.Add("README.md"); err != nil {
t.Fatalf("git add: %v", err)
}
if err := mayorGit.Commit("Initial commit"); err != nil {
t.Fatalf("git commit: %v", err)
}
// AddWithOptions needs origin/main to exist. Add self as origin and create tracking ref.
cmd = exec.Command("git", "remote", "add", "origin", mayorRig)
cmd.Dir = mayorRig
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git remote add: %v\n%s", err, out)
}
// When using a local directory as remote, fetch doesn't create tracking branches.
// Create origin/main manually since AddWithOptions expects origin/main by default.
cmd = exec.Command("git", "update-ref", "refs/remotes/origin/main", "HEAD")
cmd.Dir = mayorRig
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git update-ref: %v\n%s", err, out)
}
// Now create AGENTS.md in mayor/rig (but NOT committed to git)
// This simulates the fallback scenario
agentsMDContent := []byte("# AGENTS.md\n\nFallback content.\n")
agentsMDPath := filepath.Join(mayorRig, "AGENTS.md")
if err := os.WriteFile(agentsMDPath, agentsMDContent, 0644); err != nil {
t.Fatalf("write AGENTS.md: %v", err)
}
// Create rig pointing to root
r := &rig.Rig{
Name: "rig",
Path: root,
}
m := NewManager(r, git.NewGit(root))
// Create polecat via AddWithOptions
polecat, err := m.AddWithOptions("TestFallback", AddOptions{})
if err != nil {
t.Fatalf("AddWithOptions: %v", err)
}
// Verify AGENTS.md exists in the worktree (via fallback copy)
worktreeAgentsMD := filepath.Join(polecat.ClonePath, "AGENTS.md")
if _, err := os.Stat(worktreeAgentsMD); os.IsNotExist(err) {
t.Errorf("AGENTS.md does not exist in worktree (fallback failed) at %s", worktreeAgentsMD)
}
// Verify content matches the fallback source
content, err := os.ReadFile(worktreeAgentsMD)
if err != nil {
t.Fatalf("read worktree AGENTS.md: %v", err)
}
if string(content) != string(agentsMDContent) {
t.Errorf("AGENTS.md content = %q, want %q", string(content), string(agentsMDContent))
}
}