Files
gastown/internal/cmd/role_e2e_test.go
julianknutsen 2343e6b0ef feat: add RoleEnvVars and improve gt role CLI
Introduces config.RoleEnvVars() as the single source of truth for role
identity environment variables (GT_ROLE, GT_RIG, BD_ACTOR, etc.).

CLI improvements:
- Fix getRoleHome paths (witness has no /rig suffix, polecat/crew do)
- Make gt role env read-only (displays current role from env/cwd)
- Add EnvIncomplete handling: fill missing env vars from cwd with warning
- Add cwd mismatch warnings when not in role home directory
- gt role home now validates --polecat requires --rig

Includes comprehensive e2e tests for all role detection scenarios.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:09 -08:00

1057 lines
30 KiB
Go

//go:build integration
package cmd
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// cleanGTEnv returns os.Environ() with all GT_* variables removed.
// This ensures tests don't inherit stale role environment from CI or previous tests.
func cleanGTEnv() []string {
var clean []string
for _, env := range os.Environ() {
if !strings.HasPrefix(env, "GT_") {
clean = append(clean, env)
}
}
return clean
}
// TestRoleHomeE2E validates that gt role home returns correct paths
// for all role types after a full gt install.
func TestRoleHomeE2E(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
rigName := "testrig"
tests := []struct {
name string
args []string
expected string
}{
{
name: "mayor",
args: []string{"role", "home", "mayor"},
expected: filepath.Join(hqPath, "mayor"),
},
{
name: "deacon",
args: []string{"role", "home", "deacon"},
expected: filepath.Join(hqPath, "deacon"),
},
{
name: "witness",
args: []string{"role", "home", "witness", "--rig", rigName},
expected: filepath.Join(hqPath, rigName, "witness"),
},
{
name: "refinery",
args: []string{"role", "home", "refinery", "--rig", rigName},
expected: filepath.Join(hqPath, rigName, "refinery", "rig"),
},
{
name: "polecat",
args: []string{"role", "home", "polecat", "--rig", rigName, "--polecat", "Toast"},
expected: filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
},
{
name: "crew",
args: []string{"role", "home", "crew", "--rig", rigName, "--polecat", "worker1"},
expected: filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(gtBinary, tt.args...)
cmd.Dir = hqPath
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
// Use Output() to only capture stdout (warnings go to stderr)
output, err := cmd.Output()
if err != nil {
t.Fatalf("gt %v failed: %v\nOutput: %s", tt.args, err, output)
}
got := strings.TrimSpace(string(output))
if got != tt.expected {
t.Errorf("gt %v = %q, want %q", tt.args, got, tt.expected)
}
})
}
}
// TestRoleHomeMissingFlags validates that gt role home fails when required flags are missing.
func TestRoleHomeMissingFlags(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
tests := []struct {
name string
args []string
}{
{
name: "witness without --rig",
args: []string{"role", "home", "witness"},
},
{
name: "refinery without --rig",
args: []string{"role", "home", "refinery"},
},
{
name: "polecat without --rig",
args: []string{"role", "home", "polecat", "--polecat", "Toast"},
},
{
name: "polecat without --polecat",
args: []string{"role", "home", "polecat", "--rig", "testrig"},
},
{
name: "crew without --rig",
args: []string{"role", "home", "crew", "--polecat", "worker1"},
},
{
name: "crew without --polecat",
args: []string{"role", "home", "crew", "--rig", "testrig"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(gtBinary, tt.args...)
cmd.Dir = hqPath
// Use cleanGTEnv to ensure no stale GT_* vars affect the test
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err == nil {
t.Errorf("gt %v should have failed but succeeded with output: %s", tt.args, output)
}
})
}
}
// TestRoleHomeCwdDetection validates gt role home without arguments detects role from cwd.
func TestRoleHomeCwdDetection(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
rigName := "testrig"
// Create rig directory structure for cwd detection
dirs := []string{
filepath.Join(hqPath, rigName, "witness"),
filepath.Join(hqPath, rigName, "refinery", "rig"),
filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
tests := []struct {
name string
cwd string
expected string
}{
{
name: "mayor from mayor dir",
cwd: filepath.Join(hqPath, "mayor"),
expected: filepath.Join(hqPath, "mayor"),
},
{
name: "deacon from deacon dir",
cwd: filepath.Join(hqPath, "deacon"),
expected: filepath.Join(hqPath, "deacon"),
},
{
name: "witness from witness dir",
cwd: filepath.Join(hqPath, rigName, "witness"),
expected: filepath.Join(hqPath, rigName, "witness"),
},
{
name: "refinery from refinery/rig dir",
cwd: filepath.Join(hqPath, rigName, "refinery", "rig"),
expected: filepath.Join(hqPath, rigName, "refinery", "rig"),
},
{
name: "polecat from polecats/Toast/rig dir",
cwd: filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
expected: filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
},
{
name: "crew from crew/worker1/rig dir",
cwd: filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
expected: filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(gtBinary, "role", "home")
cmd.Dir = tt.cwd
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt role home failed: %v\nOutput: %s", err, output)
}
got := strings.TrimSpace(string(output))
if got != tt.expected {
t.Errorf("gt role home = %q, want %q", got, tt.expected)
}
})
}
}
// TestRoleEnvCwdDetection validates gt role env without arguments detects role from cwd.
func TestRoleEnvCwdDetection(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
rigName := "testrig"
// Create rig directory structure for cwd detection
dirs := []string{
filepath.Join(hqPath, rigName, "witness"),
filepath.Join(hqPath, rigName, "refinery", "rig"),
filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
tests := []struct {
name string
cwd string
want []string
}{
{
name: "mayor from mayor dir",
cwd: filepath.Join(hqPath, "mayor"),
want: []string{
"export GT_ROLE=mayor",
"export GT_ROLE_HOME=" + filepath.Join(hqPath, "mayor"),
},
},
{
name: "deacon from deacon dir",
cwd: filepath.Join(hqPath, "deacon"),
want: []string{
"export GT_ROLE=deacon",
"export GT_ROLE_HOME=" + filepath.Join(hqPath, "deacon"),
},
},
{
name: "witness from witness dir",
cwd: filepath.Join(hqPath, rigName, "witness"),
want: []string{
"export GT_ROLE=witness",
"export GT_RIG=" + rigName,
"export BD_ACTOR=" + rigName + "/witness",
"export GT_ROLE_HOME=" + filepath.Join(hqPath, rigName, "witness"),
},
},
{
name: "refinery from refinery/rig dir",
cwd: filepath.Join(hqPath, rigName, "refinery", "rig"),
want: []string{
"export GT_ROLE=refinery",
"export GT_RIG=" + rigName,
"export BD_ACTOR=" + rigName + "/refinery",
"export GT_ROLE_HOME=" + filepath.Join(hqPath, rigName, "refinery", "rig"),
},
},
{
name: "polecat from polecats/Toast/rig dir",
cwd: filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
want: []string{
"export GT_ROLE=polecat",
"export GT_RIG=" + rigName,
"export GT_POLECAT=Toast",
"export BD_ACTOR=" + rigName + "/polecats/Toast",
"export GT_ROLE_HOME=" + filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
},
},
{
name: "crew from crew/worker1/rig dir",
cwd: filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
want: []string{
"export GT_ROLE=crew",
"export GT_RIG=" + rigName,
"export GT_CREW=worker1",
"export BD_ACTOR=" + rigName + "/crew/worker1",
"export GT_ROLE_HOME=" + filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(gtBinary, "role", "env")
cmd.Dir = tt.cwd
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt role env failed: %v\nOutput: %s", err, output)
}
got := string(output)
for _, w := range tt.want {
if !strings.Contains(got, w) {
t.Errorf("output missing %q\ngot: %s", w, got)
}
}
})
}
}
// TestRoleListE2E validates gt role list shows all roles.
func TestRoleListE2E(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
cmd = exec.Command(gtBinary, "role", "list")
cmd.Dir = hqPath
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt role list failed: %v\nOutput: %s", err, output)
}
got := string(output)
// Check header
if !strings.Contains(got, "Available roles:") {
t.Errorf("output missing 'Available roles:' header\ngot: %s", got)
}
// Check all roles are listed
roles := []string{"mayor", "deacon", "witness", "refinery", "polecat", "crew"}
for _, role := range roles {
if !strings.Contains(got, role) {
t.Errorf("output missing role %q\ngot: %s", role, got)
}
}
}
// TestRoleShowE2E validates gt role show displays correct role info.
func TestRoleShowE2E(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
rigName := "testrig"
// Create rig directory structure for cwd detection
dirs := []string{
filepath.Join(hqPath, rigName, "witness"),
filepath.Join(hqPath, rigName, "refinery", "rig"),
filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
tests := []struct {
name string
cwd string
wantRole string
wantSource string
wantHome string
wantRig string
wantWorker string
}{
{
name: "mayor from mayor dir",
cwd: filepath.Join(hqPath, "mayor"),
wantRole: "mayor",
wantSource: "cwd",
wantHome: filepath.Join(hqPath, "mayor"),
},
{
name: "deacon from deacon dir",
cwd: filepath.Join(hqPath, "deacon"),
wantRole: "deacon",
wantSource: "cwd",
wantHome: filepath.Join(hqPath, "deacon"),
},
{
name: "witness from witness dir",
cwd: filepath.Join(hqPath, rigName, "witness"),
wantRole: "witness",
wantSource: "cwd",
wantHome: filepath.Join(hqPath, rigName, "witness"),
wantRig: rigName,
},
{
name: "polecat from polecats/Toast/rig dir",
cwd: filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
wantRole: "polecat",
wantSource: "cwd",
wantHome: filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
wantRig: rigName,
wantWorker: "Toast",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(gtBinary, "role", "show")
cmd.Dir = tt.cwd
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt role show failed: %v\nOutput: %s", err, output)
}
got := string(output)
if !strings.Contains(got, tt.wantRole) {
t.Errorf("output missing role %q\ngot: %s", tt.wantRole, got)
}
if !strings.Contains(got, "Source: "+tt.wantSource) {
t.Errorf("output missing 'Source: %s'\ngot: %s", tt.wantSource, got)
}
if !strings.Contains(got, "Home: "+tt.wantHome) {
t.Errorf("output missing 'Home: %s'\ngot: %s", tt.wantHome, got)
}
if tt.wantRig != "" {
if !strings.Contains(got, "Rig: "+tt.wantRig) {
t.Errorf("output missing 'Rig: %s'\ngot: %s", tt.wantRig, got)
}
}
if tt.wantWorker != "" {
if !strings.Contains(got, "Worker: "+tt.wantWorker) {
t.Errorf("output missing 'Worker: %s'\ngot: %s", tt.wantWorker, got)
}
}
})
}
}
// TestRoleShowMismatch validates gt role show displays mismatch warning.
func TestRoleShowMismatch(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
// Run from mayor dir but set GT_ROLE to deacon
cmd = exec.Command(gtBinary, "role", "show")
cmd.Dir = filepath.Join(hqPath, "mayor")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir, "GT_ROLE=deacon")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt role show failed: %v\nOutput: %s", err, output)
}
got := string(output)
// GT_ROLE takes precedence, so role should be deacon
if !strings.Contains(got, "deacon") {
t.Errorf("should show 'deacon' from GT_ROLE, got: %s", got)
}
// Source should be env
if !strings.Contains(got, "Source: env") {
t.Errorf("source should be 'env', got: %s", got)
}
// Should show mismatch warning
if !strings.Contains(got, "ROLE MISMATCH") {
t.Errorf("should show ROLE MISMATCH warning\ngot: %s", got)
}
// Should show both the env value and cwd suggestion
if !strings.Contains(got, "GT_ROLE=deacon") {
t.Errorf("should show GT_ROLE value\ngot: %s", got)
}
if !strings.Contains(got, "cwd suggests: mayor") {
t.Errorf("should show cwd suggestion\ngot: %s", got)
}
}
// TestRoleDetectE2E validates gt role detect uses cwd and ignores GT_ROLE.
func TestRoleDetectE2E(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
rigName := "testrig"
// Create rig directory structure for cwd detection
dirs := []string{
filepath.Join(hqPath, rigName, "witness"),
filepath.Join(hqPath, rigName, "refinery", "rig"),
filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
tests := []struct {
name string
cwd string
wantRole string
wantRig string
wantWorker string
}{
{
name: "mayor from mayor dir",
cwd: filepath.Join(hqPath, "mayor"),
wantRole: "mayor",
},
{
name: "deacon from deacon dir",
cwd: filepath.Join(hqPath, "deacon"),
wantRole: "deacon",
},
{
name: "witness from witness dir",
cwd: filepath.Join(hqPath, rigName, "witness"),
wantRole: "witness",
wantRig: rigName,
},
{
name: "refinery from refinery/rig dir",
cwd: filepath.Join(hqPath, rigName, "refinery", "rig"),
wantRole: "refinery",
wantRig: rigName,
},
{
name: "polecat from polecats/Toast/rig dir",
cwd: filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
wantRole: "polecat",
wantRig: rigName,
wantWorker: "Toast",
},
{
name: "crew from crew/worker1/rig dir",
cwd: filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
wantRole: "crew",
wantRig: rigName,
wantWorker: "worker1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(gtBinary, "role", "detect")
cmd.Dir = tt.cwd
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt role detect failed: %v\nOutput: %s", err, output)
}
got := string(output)
// Check role is detected
if !strings.Contains(got, tt.wantRole) {
t.Errorf("output missing role %q\ngot: %s", tt.wantRole, got)
}
// Check "(from cwd)" marker
if !strings.Contains(got, "(from cwd)") {
t.Errorf("output missing '(from cwd)' marker\ngot: %s", got)
}
// Check rig if expected
if tt.wantRig != "" {
if !strings.Contains(got, "Rig: "+tt.wantRig) {
t.Errorf("output missing 'Rig: %s'\ngot: %s", tt.wantRig, got)
}
}
// Check worker if expected
if tt.wantWorker != "" {
if !strings.Contains(got, "Worker: "+tt.wantWorker) {
t.Errorf("output missing 'Worker: %s'\ngot: %s", tt.wantWorker, got)
}
}
})
}
}
// TestRoleDetectIgnoresGTRole validates gt role detect ignores GT_ROLE env var.
func TestRoleDetectIgnoresGTRole(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
// Run from mayor dir but set GT_ROLE to deacon
cmd = exec.Command(gtBinary, "role", "detect")
cmd.Dir = filepath.Join(hqPath, "mayor")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir, "GT_ROLE=deacon")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt role detect failed: %v\nOutput: %s", err, output)
}
got := string(output)
// Should detect mayor from cwd, not deacon from env
if !strings.Contains(got, "mayor") {
t.Errorf("should detect 'mayor' from cwd, got: %s", got)
}
// Should show mismatch warning
if !strings.Contains(got, "Mismatch") {
t.Errorf("should show mismatch warning when GT_ROLE disagrees\ngot: %s", got)
}
if !strings.Contains(got, "GT_ROLE=deacon") {
t.Errorf("should show conflicting GT_ROLE value\ngot: %s", got)
}
}
// TestRoleDetectInvalidPaths validates detection behavior for incomplete/invalid paths.
func TestRoleDetectInvalidPaths(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
rigName := "testrig"
// Create incomplete directory structures
dirs := []string{
filepath.Join(hqPath, rigName), // rig root
filepath.Join(hqPath, rigName, "polecats"), // polecats without name
filepath.Join(hqPath, rigName, "crew"), // crew without name
filepath.Join(hqPath, rigName, "refinery"), // refinery without /rig
filepath.Join(hqPath, rigName, "witness"), // witness (valid - no /rig needed)
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
tests := []struct {
name string
cwd string
wantRole string
}{
{
name: "rig root returns unknown",
cwd: filepath.Join(hqPath, rigName),
wantRole: "unknown",
},
{
name: "polecats without name returns unknown",
cwd: filepath.Join(hqPath, rigName, "polecats"),
wantRole: "unknown",
},
{
name: "crew without name returns unknown",
cwd: filepath.Join(hqPath, rigName, "crew"),
wantRole: "unknown",
},
{
name: "refinery without /rig still detects refinery",
cwd: filepath.Join(hqPath, rigName, "refinery"),
wantRole: "refinery",
},
{
name: "witness without /rig detects witness",
cwd: filepath.Join(hqPath, rigName, "witness"),
wantRole: "witness",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(gtBinary, "role", "detect")
cmd.Dir = tt.cwd
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt role detect failed: %v\nOutput: %s", err, output)
}
got := string(output)
if !strings.Contains(got, tt.wantRole) {
t.Errorf("expected role %q\ngot: %s", tt.wantRole, got)
}
})
}
}
// TestRoleEnvIncompleteEnvVars validates gt role env fills gaps from cwd with warning.
func TestRoleEnvIncompleteEnvVars(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
rigName := "testrig"
// Create rig directory structure for cwd detection
dirs := []string{
filepath.Join(hqPath, rigName, "witness"),
filepath.Join(hqPath, rigName, "refinery", "rig"),
filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
tests := []struct {
name string
cwd string
envVars []string
wantExport []string // Expected exports in stdout
wantStderr string // Expected warning in stderr
}{
{
name: "GT_ROLE=witness without GT_RIG, filled from cwd",
cwd: filepath.Join(hqPath, rigName, "witness"),
envVars: []string{"GT_ROLE=witness"},
wantExport: []string{
"export GT_ROLE=witness",
"export GT_RIG=" + rigName,
"export BD_ACTOR=" + rigName + "/witness",
},
wantStderr: "env vars incomplete",
},
{
name: "GT_ROLE=refinery without GT_RIG, filled from cwd",
cwd: filepath.Join(hqPath, rigName, "refinery", "rig"),
envVars: []string{"GT_ROLE=refinery"},
wantExport: []string{
"export GT_ROLE=refinery",
"export GT_RIG=" + rigName,
"export BD_ACTOR=" + rigName + "/refinery",
},
wantStderr: "env vars incomplete",
},
{
name: "GT_ROLE=polecat without GT_RIG or GT_POLECAT, filled from cwd",
cwd: filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
envVars: []string{"GT_ROLE=polecat"},
wantExport: []string{
"export GT_ROLE=polecat",
"export GT_RIG=" + rigName,
"export GT_POLECAT=Toast",
"export BD_ACTOR=" + rigName + "/polecats/Toast",
},
wantStderr: "env vars incomplete",
},
{
name: "GT_ROLE=polecat with GT_RIG but no GT_POLECAT, filled from cwd",
cwd: filepath.Join(hqPath, rigName, "polecats", "Toast", "rig"),
envVars: []string{"GT_ROLE=polecat", "GT_RIG=" + rigName},
wantExport: []string{
"export GT_ROLE=polecat",
"export GT_RIG=" + rigName,
"export GT_POLECAT=Toast",
"export BD_ACTOR=" + rigName + "/polecats/Toast",
},
wantStderr: "env vars incomplete",
},
{
name: "GT_ROLE=crew without GT_RIG or GT_CREW, filled from cwd",
cwd: filepath.Join(hqPath, rigName, "crew", "worker1", "rig"),
envVars: []string{"GT_ROLE=crew"},
wantExport: []string{
"export GT_ROLE=crew",
"export GT_RIG=" + rigName,
"export GT_CREW=worker1",
"export BD_ACTOR=" + rigName + "/crew/worker1",
},
wantStderr: "env vars incomplete",
},
{
name: "Complete env vars - no warning",
cwd: filepath.Join(hqPath, rigName, "witness"),
envVars: []string{"GT_ROLE=witness", "GT_RIG=" + rigName},
wantExport: []string{
"export GT_ROLE=witness",
"export GT_RIG=" + rigName,
"export BD_ACTOR=" + rigName + "/witness",
},
wantStderr: "", // No warning expected
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(gtBinary, "role", "env")
cmd.Dir = tt.cwd
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
cmd.Env = append(cmd.Env, tt.envVars...)
// Use CombinedOutput to see stderr for debugging, but separate stdout/stderr
stdout, _ := cmd.Output() // Only stdout
// Re-run to get stderr
cmd2 := exec.Command(gtBinary, "role", "env")
cmd2.Dir = tt.cwd
cmd2.Env = append(cleanGTEnv(), "HOME="+tmpDir)
cmd2.Env = append(cmd2.Env, tt.envVars...)
combined, _ := cmd2.CombinedOutput()
stderr := strings.TrimPrefix(string(combined), string(stdout))
// Check expected exports in stdout
gotStdout := string(stdout)
for _, w := range tt.wantExport {
if !strings.Contains(gotStdout, w) {
t.Errorf("stdout missing %q\ngot: %s", w, gotStdout)
}
}
// Check expected warning in stderr
if tt.wantStderr != "" {
if !strings.Contains(stderr, tt.wantStderr) {
t.Errorf("stderr should contain %q\ngot: %s\ncombined: %s", tt.wantStderr, stderr, combined)
}
} else {
if strings.Contains(stderr, "incomplete") {
t.Errorf("stderr should not contain 'incomplete' warning\ngot: %s", stderr)
}
}
})
}
}
// TestRoleEnvCwdMismatchFromIncompleteDir validates warnings when in incomplete directories.
func TestRoleEnvCwdMismatchFromIncompleteDir(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
rigName := "testrig"
// Create incomplete directory structures (missing /rig)
dirs := []string{
filepath.Join(hqPath, rigName, "refinery"), // refinery without /rig
filepath.Join(hqPath, rigName, "polecats", "Toast"), // polecat without /rig
filepath.Join(hqPath, rigName, "crew", "worker1"), // crew without /rig
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
tests := []struct {
name string
cwd string
envVars []string
wantStderr string // Expected warning about cwd mismatch
}{
{
name: "refinery without /rig shows cwd mismatch",
cwd: filepath.Join(hqPath, rigName, "refinery"),
envVars: []string{"GT_ROLE=refinery", "GT_RIG=" + rigName},
wantStderr: "cwd",
},
{
name: "polecat without /rig shows cwd mismatch",
cwd: filepath.Join(hqPath, rigName, "polecats", "Toast"),
envVars: []string{"GT_ROLE=polecat", "GT_RIG=" + rigName, "GT_POLECAT=Toast"},
wantStderr: "cwd",
},
{
name: "crew without /rig shows cwd mismatch",
cwd: filepath.Join(hqPath, rigName, "crew", "worker1"),
envVars: []string{"GT_ROLE=crew", "GT_RIG=" + rigName, "GT_CREW=worker1"},
wantStderr: "cwd",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(gtBinary, "role", "env")
cmd.Dir = tt.cwd
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
cmd.Env = append(cmd.Env, tt.envVars...)
combined, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt role env failed: %v\nOutput: %s", err, combined)
}
// Check for cwd mismatch warning
if !strings.Contains(string(combined), tt.wantStderr) {
t.Errorf("output should contain %q warning\ngot: %s", tt.wantStderr, combined)
}
})
}
}
// TestRoleHomeInvalidPaths validates that commands fail gracefully for incomplete paths.
func TestRoleHomeInvalidPaths(t *testing.T) {
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
rigName := "testrig"
// Create incomplete directory structures
dirs := []string{
filepath.Join(hqPath, rigName),
filepath.Join(hqPath, rigName, "polecats"),
filepath.Join(hqPath, rigName, "crew"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
tests := []struct {
name string
cwd string
shouldErr bool
}{
{
name: "rig root fails",
cwd: filepath.Join(hqPath, rigName),
shouldErr: true,
},
{
name: "polecats without name fails",
cwd: filepath.Join(hqPath, rigName, "polecats"),
shouldErr: true,
},
{
name: "crew without name fails",
cwd: filepath.Join(hqPath, rigName, "crew"),
shouldErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(gtBinary, "role", "home")
cmd.Dir = tt.cwd
cmd.Env = append(cleanGTEnv(), "HOME="+tmpDir)
_, err := cmd.CombinedOutput()
if tt.shouldErr && err == nil {
t.Errorf("expected error but command succeeded")
}
if !tt.shouldErr && err != nil {
t.Errorf("expected success but got error: %v", err)
}
})
}
}