test: Add integration tests for gt rig add command

Add comprehensive integration tests that validate gt rig add:
- TestRigAddCreatesCorrectStructure: Verify directory structure
- TestRigAddInitializesBeads: Verify beads prefix initialization
- TestRigAddUpdatesRoutes: Verify routes.jsonl is updated
- TestRigAddUpdatesRigsJson: Verify rigs.json is updated
- TestRigAddDerivesPrefix: Verify prefix derivation from name
- TestRigAddCreatesRigConfig: Verify config.json content
- TestRigAddCreatesAgentDirs: Verify agent state files
- TestRigAddRejectsInvalidNames: Verify name validation

Uses //go:build integration tag per design doc.
Run with: go test -tags=integration ./internal/cmd -run TestRigAdd

(gt-htlmp.3)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
capable
2026-01-02 18:29:00 -08:00
committed by Steve Yegge
parent 8f6e2d2174
commit fa26265b32

View File

@@ -0,0 +1,598 @@
//go:build integration
// Package cmd contains integration tests for the rig command.
//
// Run with: go test -tags=integration ./internal/cmd -run TestRigAdd -v
package cmd
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
)
// createTestGitRepo creates a minimal git repository for testing.
// Returns the path to the bare repo URL (suitable for cloning).
func createTestGitRepo(t *testing.T, name string) string {
t.Helper()
// Create a regular repo with initial commit
repoDir := filepath.Join(t.TempDir(), name)
if err := os.MkdirAll(repoDir, 0755); err != nil {
t.Fatalf("mkdir repo: %v", err)
}
// Initialize git repo
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test User"},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = repoDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// Create initial file and commit
readmePath := filepath.Join(repoDir, "README.md")
if err := os.WriteFile(readmePath, []byte("# Test Repo\n"), 0644); err != nil {
t.Fatalf("write README: %v", err)
}
commitCmds := [][]string{
{"git", "add", "."},
{"git", "commit", "-m", "Initial commit"},
}
for _, args := range commitCmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = repoDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// Return the path as a file:// URL
return repoDir
}
// setupTestTown creates a minimal Gas Town workspace for testing.
// Returns townRoot and a cleanup function.
func setupTestTown(t *testing.T) string {
t.Helper()
townRoot := t.TempDir()
// Create mayor directory (required for rigs.json)
mayorDir := filepath.Join(townRoot, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatalf("mkdir mayor: %v", err)
}
// Create empty rigs.json
rigsPath := filepath.Join(mayorDir, "rigs.json")
rigsConfig := &config.RigsConfig{
Version: 1,
Rigs: make(map[string]config.RigEntry),
}
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
t.Fatalf("save rigs.json: %v", err)
}
// Create .beads directory for routes
beadsDir := filepath.Join(townRoot, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("mkdir .beads: %v", err)
}
return townRoot
}
// mockBdCommand creates a fake bd binary that simulates bd behavior.
// This avoids needing bd installed for tests.
func mockBdCommand(t *testing.T) {
t.Helper()
binDir := t.TempDir()
bdPath := filepath.Join(binDir, "bd")
// Create a script that simulates bd init and other commands
script := `#!/bin/sh
# Mock bd for testing
case "$1" in
init)
# Create .beads directory and config.yaml
mkdir -p .beads
prefix="gt"
for arg in "$@"; do
case "$arg" in
--prefix=*) prefix="${arg#--prefix=}" ;;
--prefix)
# Next arg is the prefix
shift
if [ -n "$1" ] && [ "$1" != "--"* ]; then
prefix="$1"
fi
;;
esac
shift
done
# Handle positional --prefix VALUE
shift # skip 'init'
while [ $# -gt 0 ]; do
case "$1" in
--prefix)
shift
prefix="$1"
;;
esac
shift
done
echo "prefix: $prefix" > .beads/config.yaml
exit 0
;;
migrate)
exit 0
;;
show)
echo '{"error":"not found"}' >&2
exit 1
;;
create)
# Return minimal JSON for agent bead creation
echo '{}'
exit 0
;;
mol|list)
exit 0
;;
*)
exit 0
;;
esac
`
if err := os.WriteFile(bdPath, []byte(script), 0755); err != nil {
t.Fatalf("write mock bd: %v", err)
}
// Prepend to PATH
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
// TestRigAddCreatesCorrectStructure verifies that gt rig add creates
// the expected directory structure.
func TestRigAddCreatesCorrectStructure(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "testproject")
// Load rigs config
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
// Create rig manager and add rig
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
_, err = mgr.AddRig(rig.AddRigOptions{
Name: "testrig",
GitURL: gitURL,
BeadsPrefix: "tr",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
rigPath := filepath.Join(townRoot, "testrig")
// Verify directory structure
expectedDirs := []string{
"", // rig root
"mayor", // mayor container
"mayor/rig", // mayor clone
"refinery", // refinery container
"refinery/rig", // refinery worktree
"witness", // witness dir
"polecats", // polecats dir
"crew", // crew dir
".beads", // beads dir
"plugins", // plugins dir
}
for _, dir := range expectedDirs {
path := filepath.Join(rigPath, dir)
info, err := os.Stat(path)
if err != nil {
t.Errorf("expected directory %s to exist: %v", dir, err)
continue
}
if !info.IsDir() {
t.Errorf("expected %s to be a directory", dir)
}
}
// Verify config.json exists
configPath := filepath.Join(rigPath, "config.json")
if _, err := os.Stat(configPath); err != nil {
t.Errorf("config.json not found: %v", err)
}
// Verify .repo.git (bare repo) exists
bareRepoPath := filepath.Join(rigPath, ".repo.git")
if _, err := os.Stat(bareRepoPath); err != nil {
t.Errorf(".repo.git not found: %v", err)
}
// Verify mayor/rig is a git repo
mayorRigPath := filepath.Join(rigPath, "mayor", "rig")
gitDirPath := filepath.Join(mayorRigPath, ".git")
if _, err := os.Stat(gitDirPath); err != nil {
t.Errorf("mayor/rig/.git not found: %v", err)
}
// Verify refinery/rig is a git worktree (has .git file pointing to bare repo)
refineryRigPath := filepath.Join(rigPath, "refinery", "rig")
refineryGitPath := filepath.Join(refineryRigPath, ".git")
info, err := os.Stat(refineryGitPath)
if err != nil {
t.Errorf("refinery/rig/.git not found: %v", err)
} else if info.IsDir() {
t.Errorf("refinery/rig/.git should be a file (worktree), not a directory")
}
}
// TestRigAddInitializesBeads verifies that beads is initialized with
// the correct prefix.
func TestRigAddInitializesBeads(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "beadstest")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
newRig, err := mgr.AddRig(rig.AddRigOptions{
Name: "beadstest",
GitURL: gitURL,
BeadsPrefix: "bt",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
// Verify rig config has correct prefix
if newRig.Config == nil {
t.Fatal("rig.Config is nil")
}
if newRig.Config.Prefix != "bt" {
t.Errorf("rig.Config.Prefix = %q, want %q", newRig.Config.Prefix, "bt")
}
// Verify .beads directory was created
beadsDir := filepath.Join(townRoot, "beadstest", ".beads")
if _, err := os.Stat(beadsDir); err != nil {
t.Errorf(".beads directory not found: %v", err)
}
// Verify config.yaml exists with correct prefix
configPath := filepath.Join(beadsDir, "config.yaml")
if _, err := os.Stat(configPath); err != nil {
t.Errorf(".beads/config.yaml not found: %v", err)
} else {
content, err := os.ReadFile(configPath)
if err != nil {
t.Errorf("reading config.yaml: %v", err)
} else if !strings.Contains(string(content), "prefix: bt") && !strings.Contains(string(content), "prefix:bt") {
t.Errorf("config.yaml doesn't contain expected prefix, got: %s", string(content))
}
}
}
// TestRigAddUpdatesRoutes verifies that routes.jsonl is updated
// with the new rig's route.
func TestRigAddUpdatesRoutes(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "routetest")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
newRig, err := mgr.AddRig(rig.AddRigOptions{
Name: "routetest",
GitURL: gitURL,
BeadsPrefix: "rt",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
// Append route to routes.jsonl (this is done by the CLI command, not AddRig)
// The CLI command in runRigAdd calls beads.AppendRoute after AddRig succeeds
if newRig.Config != nil && newRig.Config.Prefix != "" {
route := beads.Route{
Prefix: newRig.Config.Prefix + "-",
Path: "routetest",
}
if err := beads.AppendRoute(townRoot, route); err != nil {
t.Fatalf("AppendRoute: %v", err)
}
}
// Save rigs config (normally done by the command)
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
t.Fatalf("save rigs.json: %v", err)
}
// Load routes and verify the new route exists
townBeadsDir := filepath.Join(townRoot, ".beads")
routes, err := beads.LoadRoutes(townBeadsDir)
if err != nil {
t.Fatalf("LoadRoutes: %v", err)
}
// Find route for our rig
var foundRoute *beads.Route
for _, r := range routes {
if r.Prefix == "rt-" {
foundRoute = &r
break
}
}
if foundRoute == nil {
t.Error("route with prefix 'rt-' not found in routes.jsonl")
t.Logf("routes: %+v", routes)
} else {
// The path should point to the rig (or mayor/rig if .beads is tracked in source)
if !strings.HasPrefix(foundRoute.Path, "routetest") {
t.Errorf("route path = %q, want prefix 'routetest'", foundRoute.Path)
}
}
}
// TestRigAddUpdatesRigsJson verifies that rigs.json is updated
// with the new rig entry.
func TestRigAddUpdatesRigsJson(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "jsontest")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
_, err = mgr.AddRig(rig.AddRigOptions{
Name: "jsontest",
GitURL: gitURL,
BeadsPrefix: "jt",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
// Save rigs config (normally done by the command)
if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil {
t.Fatalf("save rigs.json: %v", err)
}
// Reload and verify
rigsConfig2, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("reload rigs.json: %v", err)
}
entry, ok := rigsConfig2.Rigs["jsontest"]
if !ok {
t.Error("rig 'jsontest' not found in rigs.json")
t.Logf("rigs: %+v", rigsConfig2.Rigs)
} else {
if entry.GitURL != gitURL {
t.Errorf("GitURL = %q, want %q", entry.GitURL, gitURL)
}
if entry.BeadsConfig == nil {
t.Error("BeadsConfig is nil")
} else if entry.BeadsConfig.Prefix != "jt" {
t.Errorf("BeadsConfig.Prefix = %q, want %q", entry.BeadsConfig.Prefix, "jt")
}
}
}
// TestRigAddDerivesPrefix verifies that when no prefix is specified,
// one is derived from the rig name.
func TestRigAddDerivesPrefix(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "myproject")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
newRig, err := mgr.AddRig(rig.AddRigOptions{
Name: "myproject",
GitURL: gitURL,
// No BeadsPrefix - should be derived
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
// For a single-word name like "myproject", the prefix should be first 2 chars
if newRig.Config.Prefix != "my" {
t.Errorf("derived prefix = %q, want %q", newRig.Config.Prefix, "my")
}
}
// TestRigAddCreatesRigConfig verifies that config.json contains
// the correct rig configuration.
func TestRigAddCreatesRigConfig(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "configtest")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
_, err = mgr.AddRig(rig.AddRigOptions{
Name: "configtest",
GitURL: gitURL,
BeadsPrefix: "ct",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
// Read and verify config.json
configPath := filepath.Join(townRoot, "configtest", "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("reading config.json: %v", err)
}
var rigCfg rig.RigConfig
if err := json.Unmarshal(data, &rigCfg); err != nil {
t.Fatalf("parsing config.json: %v", err)
}
if rigCfg.Type != "rig" {
t.Errorf("Type = %q, want 'rig'", rigCfg.Type)
}
if rigCfg.Name != "configtest" {
t.Errorf("Name = %q, want 'configtest'", rigCfg.Name)
}
if rigCfg.GitURL != gitURL {
t.Errorf("GitURL = %q, want %q", rigCfg.GitURL, gitURL)
}
if rigCfg.Beads == nil {
t.Error("Beads config is nil")
} else if rigCfg.Beads.Prefix != "ct" {
t.Errorf("Beads.Prefix = %q, want 'ct'", rigCfg.Beads.Prefix)
}
if rigCfg.DefaultBranch == "" {
t.Error("DefaultBranch is empty")
}
}
// TestRigAddCreatesAgentDirs verifies that agent state files are created.
func TestRigAddCreatesAgentDirs(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "agenttest")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
_, err = mgr.AddRig(rig.AddRigOptions{
Name: "agenttest",
GitURL: gitURL,
BeadsPrefix: "at",
})
if err != nil {
t.Fatalf("AddRig: %v", err)
}
rigPath := filepath.Join(townRoot, "agenttest")
// Verify agent state files exist
expectedStateFiles := []string{
"witness/state.json",
"refinery/state.json",
"mayor/state.json",
}
for _, stateFile := range expectedStateFiles {
path := filepath.Join(rigPath, stateFile)
if _, err := os.Stat(path); err != nil {
t.Errorf("expected state file %s to exist: %v", stateFile, err)
}
}
}
// TestRigAddRejectsInvalidNames verifies that rig names with invalid
// characters are rejected.
func TestRigAddRejectsInvalidNames(t *testing.T) {
mockBdCommand(t)
townRoot := setupTestTown(t)
gitURL := createTestGitRepo(t, "validname")
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("load rigs.json: %v", err)
}
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
// Characters that break agent ID parsing (hyphens, dots, spaces)
// Note: underscores are allowed
invalidNames := []string{
"my-rig", // hyphens break agent ID parsing
"my.rig", // dots break parsing
"my rig", // spaces are invalid
"my-multi-rig", // multiple hyphens
}
for _, name := range invalidNames {
t.Run(name, func(t *testing.T) {
_, err := mgr.AddRig(rig.AddRigOptions{
Name: name,
GitURL: gitURL,
})
if err == nil {
t.Errorf("AddRig(%q) should have failed", name)
} else if !strings.Contains(err.Error(), "invalid characters") {
t.Errorf("AddRig(%q) error = %v, want 'invalid characters'", name, err)
}
})
}
}