Add comprehensive integration tests covering hook slot operations: - BasicHook: Verify bead can be hooked to an agent - Singleton: Document that bd allows multiple hooks (gt enforces singleton) - Unhook: Verify hook removal via status change - DifferentAgents: Verify independent hooks per agent - HookPersistence: Verify hooks survive beads instance recreation - StatusTransitions: Test open -> hooked -> open -> hooked -> closed Also fix missing json import in install_integration_test.go. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
489 lines
12 KiB
Go
489 lines
12 KiB
Go
//go:build integration
|
|
|
|
// Package cmd contains integration tests for hook slot verification.
|
|
//
|
|
// Run with: go test -tags=integration ./internal/cmd -run TestHookSlot -v
|
|
package cmd
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
)
|
|
|
|
// setupHookTestTown creates a minimal Gas Town with a polecat for testing hooks.
|
|
// Returns townRoot and the path to the polecat's worktree.
|
|
func setupHookTestTown(t *testing.T) (townRoot, polecatDir string) {
|
|
t.Helper()
|
|
|
|
townRoot = t.TempDir()
|
|
|
|
// Create town-level .beads directory
|
|
townBeadsDir := filepath.Join(townRoot, ".beads")
|
|
if err := os.MkdirAll(townBeadsDir, 0755); err != nil {
|
|
t.Fatalf("mkdir town .beads: %v", err)
|
|
}
|
|
|
|
// Create routes.jsonl
|
|
routes := []beads.Route{
|
|
{Prefix: "hq-", Path: "."}, // Town-level beads
|
|
{Prefix: "gt-", Path: "gastown/mayor/rig"}, // Gastown rig
|
|
}
|
|
if err := beads.WriteRoutes(townBeadsDir, routes); err != nil {
|
|
t.Fatalf("write routes: %v", err)
|
|
}
|
|
|
|
// Create gastown rig structure
|
|
gasRigPath := filepath.Join(townRoot, "gastown", "mayor", "rig")
|
|
if err := os.MkdirAll(gasRigPath, 0755); err != nil {
|
|
t.Fatalf("mkdir gastown: %v", err)
|
|
}
|
|
|
|
// Create gastown .beads directory with its own config
|
|
gasBeadsDir := filepath.Join(gasRigPath, ".beads")
|
|
if err := os.MkdirAll(gasBeadsDir, 0755); err != nil {
|
|
t.Fatalf("mkdir gastown .beads: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(gasBeadsDir, "config.yaml"), []byte("prefix: gt\n"), 0644); err != nil {
|
|
t.Fatalf("write gastown config: %v", err)
|
|
}
|
|
|
|
// Create polecat worktree with redirect
|
|
polecatDir = filepath.Join(townRoot, "gastown", "polecats", "toast")
|
|
if err := os.MkdirAll(polecatDir, 0755); err != nil {
|
|
t.Fatalf("mkdir polecats: %v", err)
|
|
}
|
|
|
|
// Create redirect file for polecat -> mayor/rig/.beads
|
|
polecatBeadsDir := filepath.Join(polecatDir, ".beads")
|
|
if err := os.MkdirAll(polecatBeadsDir, 0755); err != nil {
|
|
t.Fatalf("mkdir polecat .beads: %v", err)
|
|
}
|
|
redirectContent := "../../mayor/rig/.beads"
|
|
if err := os.WriteFile(filepath.Join(polecatBeadsDir, "redirect"), []byte(redirectContent), 0644); err != nil {
|
|
t.Fatalf("write redirect: %v", err)
|
|
}
|
|
|
|
return townRoot, polecatDir
|
|
}
|
|
|
|
// initBeadsDB initializes the beads database by running bd init.
|
|
func initBeadsDB(t *testing.T, dir string) {
|
|
t.Helper()
|
|
|
|
cmd := exec.Command("bd", "--no-daemon", "init")
|
|
cmd.Dir = dir
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("bd init failed: %v\n%s", err, output)
|
|
}
|
|
}
|
|
|
|
// TestHookSlot_BasicHook verifies that a bead can be hooked to an agent.
|
|
func TestHookSlot_BasicHook(t *testing.T) {
|
|
// Skip if bd is not available
|
|
if _, err := exec.LookPath("bd"); err != nil {
|
|
t.Skip("bd not installed, skipping test")
|
|
}
|
|
|
|
townRoot, polecatDir := setupHookTestTown(t)
|
|
_ = townRoot // Not used directly but shows test context
|
|
|
|
// Initialize beads in the rig
|
|
rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig")
|
|
initBeadsDB(t, rigDir)
|
|
|
|
b := beads.New(rigDir)
|
|
|
|
// Create a test bead
|
|
issue, err := b.Create(beads.CreateOptions{
|
|
Title: "Test task for hooking",
|
|
Type: "task",
|
|
Priority: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create bead: %v", err)
|
|
}
|
|
t.Logf("Created bead: %s", issue.ID)
|
|
|
|
// Hook the bead to the polecat
|
|
agentID := "gastown/polecats/toast"
|
|
status := beads.StatusHooked
|
|
if err := b.Update(issue.ID, beads.UpdateOptions{
|
|
Status: &status,
|
|
Assignee: &agentID,
|
|
}); err != nil {
|
|
t.Fatalf("hook bead: %v", err)
|
|
}
|
|
|
|
// Verify the bead is hooked
|
|
hookedBeads, err := b.List(beads.ListOptions{
|
|
Status: beads.StatusHooked,
|
|
Assignee: agentID,
|
|
Priority: -1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list hooked beads: %v", err)
|
|
}
|
|
|
|
if len(hookedBeads) != 1 {
|
|
t.Errorf("expected 1 hooked bead, got %d", len(hookedBeads))
|
|
}
|
|
|
|
if len(hookedBeads) > 0 && hookedBeads[0].ID != issue.ID {
|
|
t.Errorf("hooked bead ID = %s, want %s", hookedBeads[0].ID, issue.ID)
|
|
}
|
|
}
|
|
|
|
// TestHookSlot_Singleton verifies that only one bead can be hooked per agent.
|
|
func TestHookSlot_Singleton(t *testing.T) {
|
|
if _, err := exec.LookPath("bd"); err != nil {
|
|
t.Skip("bd not installed, skipping test")
|
|
}
|
|
|
|
townRoot, polecatDir := setupHookTestTown(t)
|
|
_ = townRoot
|
|
|
|
rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig")
|
|
initBeadsDB(t, rigDir)
|
|
|
|
b := beads.New(rigDir)
|
|
agentID := "gastown/polecats/toast"
|
|
status := beads.StatusHooked
|
|
|
|
// Create and hook first bead
|
|
issue1, err := b.Create(beads.CreateOptions{
|
|
Title: "First task",
|
|
Type: "task",
|
|
Priority: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create first bead: %v", err)
|
|
}
|
|
|
|
if err := b.Update(issue1.ID, beads.UpdateOptions{
|
|
Status: &status,
|
|
Assignee: &agentID,
|
|
}); err != nil {
|
|
t.Fatalf("hook first bead: %v", err)
|
|
}
|
|
|
|
// Create second bead
|
|
issue2, err := b.Create(beads.CreateOptions{
|
|
Title: "Second task",
|
|
Type: "task",
|
|
Priority: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create second bead: %v", err)
|
|
}
|
|
|
|
// Hook second bead to same agent
|
|
if err := b.Update(issue2.ID, beads.UpdateOptions{
|
|
Status: &status,
|
|
Assignee: &agentID,
|
|
}); err != nil {
|
|
t.Fatalf("hook second bead: %v", err)
|
|
}
|
|
|
|
// Query hooked beads - both should be hooked (bd allows multiple)
|
|
// The singleton constraint is enforced by gt hook, not bd itself
|
|
hookedBeads, err := b.List(beads.ListOptions{
|
|
Status: beads.StatusHooked,
|
|
Assignee: agentID,
|
|
Priority: -1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list hooked beads: %v", err)
|
|
}
|
|
|
|
t.Logf("Found %d hooked beads for agent %s", len(hookedBeads), agentID)
|
|
for _, h := range hookedBeads {
|
|
t.Logf(" - %s: %s", h.ID, h.Title)
|
|
}
|
|
|
|
// The test documents actual behavior: bd allows multiple hooked beads
|
|
// The gt hook command enforces singleton behavior
|
|
if len(hookedBeads) != 2 {
|
|
t.Errorf("expected 2 hooked beads (bd allows multiple), got %d", len(hookedBeads))
|
|
}
|
|
}
|
|
|
|
// TestHookSlot_Unhook verifies that a bead can be unhooked by changing status.
|
|
func TestHookSlot_Unhook(t *testing.T) {
|
|
if _, err := exec.LookPath("bd"); err != nil {
|
|
t.Skip("bd not installed, skipping test")
|
|
}
|
|
|
|
townRoot, polecatDir := setupHookTestTown(t)
|
|
_ = townRoot
|
|
|
|
rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig")
|
|
initBeadsDB(t, rigDir)
|
|
|
|
b := beads.New(rigDir)
|
|
agentID := "gastown/polecats/toast"
|
|
|
|
// Create and hook a bead
|
|
issue, err := b.Create(beads.CreateOptions{
|
|
Title: "Task to unhook",
|
|
Type: "task",
|
|
Priority: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create bead: %v", err)
|
|
}
|
|
|
|
status := beads.StatusHooked
|
|
if err := b.Update(issue.ID, beads.UpdateOptions{
|
|
Status: &status,
|
|
Assignee: &agentID,
|
|
}); err != nil {
|
|
t.Fatalf("hook bead: %v", err)
|
|
}
|
|
|
|
// Unhook by setting status back to open
|
|
openStatus := "open"
|
|
if err := b.Update(issue.ID, beads.UpdateOptions{
|
|
Status: &openStatus,
|
|
}); err != nil {
|
|
t.Fatalf("unhook bead: %v", err)
|
|
}
|
|
|
|
// Verify no hooked beads remain
|
|
hookedBeads, err := b.List(beads.ListOptions{
|
|
Status: beads.StatusHooked,
|
|
Assignee: agentID,
|
|
Priority: -1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list hooked beads: %v", err)
|
|
}
|
|
|
|
if len(hookedBeads) != 0 {
|
|
t.Errorf("expected 0 hooked beads after unhook, got %d", len(hookedBeads))
|
|
}
|
|
}
|
|
|
|
// TestHookSlot_DifferentAgents verifies that different agents can have different hooks.
|
|
func TestHookSlot_DifferentAgents(t *testing.T) {
|
|
if _, err := exec.LookPath("bd"); err != nil {
|
|
t.Skip("bd not installed, skipping test")
|
|
}
|
|
|
|
townRoot, polecatDir := setupHookTestTown(t)
|
|
|
|
// Create second polecat directory
|
|
polecat2Dir := filepath.Join(townRoot, "gastown", "polecats", "nux")
|
|
if err := os.MkdirAll(polecat2Dir, 0755); err != nil {
|
|
t.Fatalf("mkdir polecat2: %v", err)
|
|
}
|
|
|
|
rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig")
|
|
initBeadsDB(t, rigDir)
|
|
|
|
b := beads.New(rigDir)
|
|
agent1 := "gastown/polecats/toast"
|
|
agent2 := "gastown/polecats/nux"
|
|
status := beads.StatusHooked
|
|
|
|
// Create and hook bead to first agent
|
|
issue1, err := b.Create(beads.CreateOptions{
|
|
Title: "Toast's task",
|
|
Type: "task",
|
|
Priority: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create bead 1: %v", err)
|
|
}
|
|
|
|
if err := b.Update(issue1.ID, beads.UpdateOptions{
|
|
Status: &status,
|
|
Assignee: &agent1,
|
|
}); err != nil {
|
|
t.Fatalf("hook bead to agent1: %v", err)
|
|
}
|
|
|
|
// Create and hook bead to second agent
|
|
issue2, err := b.Create(beads.CreateOptions{
|
|
Title: "Nux's task",
|
|
Type: "task",
|
|
Priority: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create bead 2: %v", err)
|
|
}
|
|
|
|
if err := b.Update(issue2.ID, beads.UpdateOptions{
|
|
Status: &status,
|
|
Assignee: &agent2,
|
|
}); err != nil {
|
|
t.Fatalf("hook bead to agent2: %v", err)
|
|
}
|
|
|
|
// Verify each agent has exactly one hook
|
|
agent1Hooks, err := b.List(beads.ListOptions{
|
|
Status: beads.StatusHooked,
|
|
Assignee: agent1,
|
|
Priority: -1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list agent1 hooks: %v", err)
|
|
}
|
|
|
|
agent2Hooks, err := b.List(beads.ListOptions{
|
|
Status: beads.StatusHooked,
|
|
Assignee: agent2,
|
|
Priority: -1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list agent2 hooks: %v", err)
|
|
}
|
|
|
|
if len(agent1Hooks) != 1 {
|
|
t.Errorf("agent1 should have 1 hook, got %d", len(agent1Hooks))
|
|
}
|
|
if len(agent2Hooks) != 1 {
|
|
t.Errorf("agent2 should have 1 hook, got %d", len(agent2Hooks))
|
|
}
|
|
|
|
// Verify correct assignment
|
|
if len(agent1Hooks) > 0 && agent1Hooks[0].ID != issue1.ID {
|
|
t.Errorf("agent1 hook ID = %s, want %s", agent1Hooks[0].ID, issue1.ID)
|
|
}
|
|
if len(agent2Hooks) > 0 && agent2Hooks[0].ID != issue2.ID {
|
|
t.Errorf("agent2 hook ID = %s, want %s", agent2Hooks[0].ID, issue2.ID)
|
|
}
|
|
}
|
|
|
|
// TestHookSlot_HookPersistence verifies that hooks persist across beads object recreation.
|
|
func TestHookSlot_HookPersistence(t *testing.T) {
|
|
if _, err := exec.LookPath("bd"); err != nil {
|
|
t.Skip("bd not installed, skipping test")
|
|
}
|
|
|
|
townRoot, polecatDir := setupHookTestTown(t)
|
|
_ = townRoot
|
|
|
|
rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig")
|
|
initBeadsDB(t, rigDir)
|
|
|
|
agentID := "gastown/polecats/toast"
|
|
status := beads.StatusHooked
|
|
|
|
// Create first beads instance and hook a bead
|
|
b1 := beads.New(rigDir)
|
|
issue, err := b1.Create(beads.CreateOptions{
|
|
Title: "Persistent task",
|
|
Type: "task",
|
|
Priority: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create bead: %v", err)
|
|
}
|
|
|
|
if err := b1.Update(issue.ID, beads.UpdateOptions{
|
|
Status: &status,
|
|
Assignee: &agentID,
|
|
}); err != nil {
|
|
t.Fatalf("hook bead: %v", err)
|
|
}
|
|
|
|
// Create new beads instance (simulates session restart)
|
|
b2 := beads.New(rigDir)
|
|
|
|
// Verify hook persists
|
|
hookedBeads, err := b2.List(beads.ListOptions{
|
|
Status: beads.StatusHooked,
|
|
Assignee: agentID,
|
|
Priority: -1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list hooked beads with new instance: %v", err)
|
|
}
|
|
|
|
if len(hookedBeads) != 1 {
|
|
t.Errorf("expected hook to persist, got %d hooked beads", len(hookedBeads))
|
|
}
|
|
|
|
if len(hookedBeads) > 0 && hookedBeads[0].ID != issue.ID {
|
|
t.Errorf("persisted hook ID = %s, want %s", hookedBeads[0].ID, issue.ID)
|
|
}
|
|
}
|
|
|
|
// TestHookSlot_StatusTransitions tests valid status transitions for hooked beads.
|
|
func TestHookSlot_StatusTransitions(t *testing.T) {
|
|
if _, err := exec.LookPath("bd"); err != nil {
|
|
t.Skip("bd not installed, skipping test")
|
|
}
|
|
|
|
townRoot, polecatDir := setupHookTestTown(t)
|
|
_ = townRoot
|
|
|
|
rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig")
|
|
initBeadsDB(t, rigDir)
|
|
|
|
b := beads.New(rigDir)
|
|
agentID := "gastown/polecats/toast"
|
|
|
|
// Create a bead
|
|
issue, err := b.Create(beads.CreateOptions{
|
|
Title: "Status transition test",
|
|
Type: "task",
|
|
Priority: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create bead: %v", err)
|
|
}
|
|
|
|
// Test transitions: open -> hooked -> open -> hooked -> closed
|
|
transitions := []struct {
|
|
name string
|
|
status string
|
|
}{
|
|
{"hook", beads.StatusHooked},
|
|
{"unhook", "open"},
|
|
{"rehook", beads.StatusHooked},
|
|
}
|
|
|
|
for _, trans := range transitions {
|
|
t.Run(trans.name, func(t *testing.T) {
|
|
status := trans.status
|
|
opts := beads.UpdateOptions{Status: &status}
|
|
if trans.status == beads.StatusHooked {
|
|
opts.Assignee = &agentID
|
|
}
|
|
|
|
if err := b.Update(issue.ID, opts); err != nil {
|
|
t.Errorf("transition to %s failed: %v", trans.status, err)
|
|
}
|
|
|
|
// Verify status
|
|
updated, err := b.Show(issue.ID)
|
|
if err != nil {
|
|
t.Errorf("show after %s: %v", trans.name, err)
|
|
return
|
|
}
|
|
if updated.Status != trans.status {
|
|
t.Errorf("status after %s = %s, want %s", trans.name, updated.Status, trans.status)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Finally close the bead
|
|
if err := b.Close(issue.ID); err != nil {
|
|
t.Errorf("close hooked bead: %v", err)
|
|
}
|
|
|
|
// Verify it's closed
|
|
closed, err := b.Show(issue.ID)
|
|
if err != nil {
|
|
t.Fatalf("show closed bead: %v", err)
|
|
}
|
|
if closed.Status != "closed" {
|
|
t.Errorf("final status = %s, want closed", closed.Status)
|
|
}
|
|
}
|