Files
gastown/internal/cmd/install_integration_test.go
Dan Shapiro 87bdd6c63e fix: create town role beads before agents
Install now creates town role beads before agents and only skips agent creation
when the exact agent bead exists, so role slots get set reliably. Add an
integration test that asserts role slots are populated after install.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 08:49:32 -08:00

365 lines
11 KiB
Go

//go:build integration
package cmd
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/gastown/internal/config"
)
// TestInstallCreatesCorrectStructure validates that a fresh gt install
// creates the expected directory structure and configuration files.
func TestInstallCreatesCorrectStructure(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
// Build gt binary for testing
gtBinary := buildGT(t)
// Run gt install
cmd := exec.Command(gtBinary, "install", hqPath, "--name", "test-town")
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
// Verify directory structure
assertDirExists(t, hqPath, "HQ root")
assertDirExists(t, filepath.Join(hqPath, "mayor"), "mayor/")
// Verify mayor/town.json
townPath := filepath.Join(hqPath, "mayor", "town.json")
assertFileExists(t, townPath, "mayor/town.json")
townConfig, err := config.LoadTownConfig(townPath)
if err != nil {
t.Fatalf("failed to load town.json: %v", err)
}
if townConfig.Type != "town" {
t.Errorf("town.json type = %q, want %q", townConfig.Type, "town")
}
if townConfig.Name != "test-town" {
t.Errorf("town.json name = %q, want %q", townConfig.Name, "test-town")
}
// Verify mayor/rigs.json
rigsPath := filepath.Join(hqPath, "mayor", "rigs.json")
assertFileExists(t, rigsPath, "mayor/rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
t.Fatalf("failed to load rigs.json: %v", err)
}
if len(rigsConfig.Rigs) != 0 {
t.Errorf("rigs.json should be empty, got %d rigs", len(rigsConfig.Rigs))
}
// Verify mayor/state.json
statePath := filepath.Join(hqPath, "mayor", "state.json")
assertFileExists(t, statePath, "mayor/state.json")
stateData, err := os.ReadFile(statePath)
if err != nil {
t.Fatalf("failed to read state.json: %v", err)
}
var state map[string]interface{}
if err := json.Unmarshal(stateData, &state); err != nil {
t.Fatalf("failed to parse state.json: %v", err)
}
if state["role"] != "mayor" {
t.Errorf("state.json role = %q, want %q", state["role"], "mayor")
}
// Verify CLAUDE.md exists
claudePath := filepath.Join(hqPath, "CLAUDE.md")
assertFileExists(t, claudePath, "CLAUDE.md")
}
// TestInstallBeadsHasCorrectPrefix validates that beads is initialized
// with the correct "hq-" prefix for town-level beads.
func TestInstallBeadsHasCorrectPrefix(t *testing.T) {
// Skip if bd is not available
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd not installed, skipping beads prefix test")
}
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
// Build gt binary for testing
gtBinary := buildGT(t)
// Run gt install (includes beads init by default)
cmd := exec.Command(gtBinary, "install", hqPath)
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
// Verify .beads/ directory exists
beadsDir := filepath.Join(hqPath, ".beads")
assertDirExists(t, beadsDir, ".beads/")
// Verify beads database was created
dbPath := filepath.Join(beadsDir, "beads.db")
assertFileExists(t, dbPath, ".beads/beads.db")
// Verify prefix by running bd config get issue_prefix
// Use --no-daemon to avoid daemon startup issues in test environment
bdCmd := exec.Command("bd", "--no-daemon", "config", "get", "issue_prefix")
bdCmd.Dir = hqPath
prefixOutput, err := bdCmd.Output() // Use Output() to get only stdout
if err != nil {
// If Output() fails, try CombinedOutput for better error info
combinedOut, _ := exec.Command("bd", "--no-daemon", "config", "get", "issue_prefix").CombinedOutput()
t.Fatalf("bd config get issue_prefix failed: %v\nOutput: %s", err, combinedOut)
}
prefix := strings.TrimSpace(string(prefixOutput))
if prefix != "hq" {
t.Errorf("beads issue_prefix = %q, want %q", prefix, "hq")
}
}
// TestInstallTownRoleSlots validates that town-level agent beads
// have their role slot set after install.
func TestInstallTownRoleSlots(t *testing.T) {
// Skip if bd is not available
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd not installed, skipping role slot test")
}
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
// Run gt install (includes beads init by default)
cmd := exec.Command(gtBinary, "install", hqPath)
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
assertSlotValue(t, hqPath, "hq-mayor", "role", "hq-mayor-role")
assertSlotValue(t, hqPath, "hq-deacon", "role", "hq-deacon-role")
}
// TestInstallIdempotent validates that running gt install twice
// on the same directory fails without --force flag.
func TestInstallIdempotent(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
// First install should succeed
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("first install failed: %v\nOutput: %s", err, output)
}
// Second install without --force should fail
cmd = exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("second install should have failed without --force")
}
if !strings.Contains(string(output), "already a Gas Town HQ") {
t.Errorf("expected 'already a Gas Town HQ' error, got: %s", output)
}
// Third install with --force should succeed
cmd = exec.Command(gtBinary, "install", hqPath, "--no-beads", "--force")
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("install with --force failed: %v\nOutput: %s", err, output)
}
}
// TestInstallFormulasProvisioned validates that embedded formulas are copied
// to .beads/formulas/ during installation.
func TestInstallFormulasProvisioned(t *testing.T) {
// Skip if bd is not available
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd not installed, skipping formulas test")
}
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
// Run gt install (includes beads and formula provisioning)
cmd := exec.Command(gtBinary, "install", hqPath)
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
// Verify .beads/formulas/ directory exists
formulasDir := filepath.Join(hqPath, ".beads", "formulas")
assertDirExists(t, formulasDir, ".beads/formulas/")
// Verify at least some expected formulas exist
expectedFormulas := []string{
"mol-deacon-patrol.formula.toml",
"mol-refinery-patrol.formula.toml",
"code-review.formula.toml",
}
for _, f := range expectedFormulas {
formulaPath := filepath.Join(formulasDir, f)
assertFileExists(t, formulaPath, f)
}
// Verify the count matches embedded formulas
entries, err := os.ReadDir(formulasDir)
if err != nil {
t.Fatalf("failed to read formulas dir: %v", err)
}
// Count only formula files (not directories)
var fileCount int
for _, e := range entries {
if !e.IsDir() {
fileCount++
}
}
// Should have at least 20 formulas (allows for some variation)
if fileCount < 20 {
t.Errorf("expected at least 20 formulas, got %d", fileCount)
}
}
// TestInstallNoBeadsFlag validates that --no-beads skips beads initialization.
func TestInstallNoBeadsFlag(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
// Run gt install with --no-beads
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt install --no-beads failed: %v\nOutput: %s", err, output)
}
// Verify .beads/ directory does NOT exist
beadsDir := filepath.Join(hqPath, ".beads")
if _, err := os.Stat(beadsDir); !os.IsNotExist(err) {
t.Errorf(".beads/ should not exist with --no-beads flag")
}
}
// buildGT builds the gt binary and returns its path.
// It caches the build across tests in the same run.
var cachedGTBinary string
func buildGT(t *testing.T) string {
t.Helper()
if cachedGTBinary != "" {
// Verify cached binary still exists
if _, err := os.Stat(cachedGTBinary); err == nil {
return cachedGTBinary
}
// Binary was cleaned up, rebuild
cachedGTBinary = ""
}
// Find project root (where go.mod is)
wd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
// Walk up to find go.mod
projectRoot := wd
for {
if _, err := os.Stat(filepath.Join(projectRoot, "go.mod")); err == nil {
break
}
parent := filepath.Dir(projectRoot)
if parent == projectRoot {
t.Fatal("could not find project root (go.mod)")
}
projectRoot = parent
}
// Build gt binary to a persistent temp location (not per-test)
tmpDir := os.TempDir()
tmpBinary := filepath.Join(tmpDir, "gt-integration-test")
cmd := exec.Command("go", "build", "-o", tmpBinary, "./cmd/gt")
cmd.Dir = projectRoot
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to build gt: %v\nOutput: %s", err, output)
}
cachedGTBinary = tmpBinary
return tmpBinary
}
// assertDirExists checks that the given path exists and is a directory.
func assertDirExists(t *testing.T, path, name string) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Errorf("%s does not exist: %v", name, err)
return
}
if !info.IsDir() {
t.Errorf("%s is not a directory", name)
}
}
// assertFileExists checks that the given path exists and is a file.
func assertFileExists(t *testing.T, path, name string) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Errorf("%s does not exist: %v", name, err)
return
}
if info.IsDir() {
t.Errorf("%s is a directory, expected file", name)
}
}
func assertSlotValue(t *testing.T, townRoot, issueID, slot, want string) {
t.Helper()
cmd := exec.Command("bd", "--no-daemon", "--json", "slot", "show", issueID)
cmd.Dir = townRoot
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bd slot show %s failed: %v\nOutput: %s", issueID, err, output)
}
var parsed struct {
Slots map[string]*string `json:"slots"`
}
if err := json.Unmarshal(output, &parsed); err != nil {
t.Fatalf("parsing slot show output failed: %v\nOutput: %s", err, output)
}
var got string
if value, ok := parsed.Slots[slot]; ok && value != nil {
got = *value
}
if got != want {
t.Fatalf("slot %s for %s = %q, want %q", slot, issueID, got, want)
}
}