//go:build integration // Package cmd contains integration tests for beads routing and redirects. // // Run with: go test -tags=integration ./internal/cmd -run TestBeadsRouting -v package cmd import ( "os" "os/exec" "path/filepath" "strings" "testing" "github.com/steveyegge/gastown/internal/beads" ) // setupRoutingTestTown creates a minimal Gas Town with multiple rigs for testing routing. // Returns townRoot. func setupRoutingTestTown(t *testing.T) 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 with multiple rigs routes := []beads.Route{ {Prefix: "hq-", Path: "."}, // Town-level beads {Prefix: "gt-", Path: "gastown/mayor/rig"}, // Gastown rig {Prefix: "tr-", Path: "testrig/mayor/rig"}, // Test 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 testrig structure testRigPath := filepath.Join(townRoot, "testrig", "mayor", "rig") if err := os.MkdirAll(testRigPath, 0755); err != nil { t.Fatalf("mkdir testrig: %v", err) } // Create testrig .beads directory testBeadsDir := filepath.Join(testRigPath, ".beads") if err := os.MkdirAll(testBeadsDir, 0755); err != nil { t.Fatalf("mkdir testrig .beads: %v", err) } if err := os.WriteFile(filepath.Join(testBeadsDir, "config.yaml"), []byte("prefix: tr\n"), 0644); err != nil { t.Fatalf("write testrig config: %v", err) } // Create polecats directory with redirect polecatsDir := filepath.Join(townRoot, "gastown", "polecats", "rictus") if err := os.MkdirAll(polecatsDir, 0755); err != nil { t.Fatalf("mkdir polecats: %v", err) } // Create redirect file for polecat -> mayor/rig/.beads // Path: gastown/polecats/rictus -> ../../mayor/rig/.beads -> gastown/mayor/rig/.beads polecatBeadsDir := filepath.Join(polecatsDir, ".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) } // Create crew directory with redirect // Path: gastown/crew/max -> ../../mayor/rig/.beads -> gastown/mayor/rig/.beads crewDir := filepath.Join(townRoot, "gastown", "crew", "max") if err := os.MkdirAll(crewDir, 0755); err != nil { t.Fatalf("mkdir crew: %v", err) } crewBeadsDir := filepath.Join(crewDir, ".beads") if err := os.MkdirAll(crewBeadsDir, 0755); err != nil { t.Fatalf("mkdir crew .beads: %v", err) } crewRedirect := "../../mayor/rig/.beads" if err := os.WriteFile(filepath.Join(crewBeadsDir, "redirect"), []byte(crewRedirect), 0644); err != nil { t.Fatalf("write crew redirect: %v", err) } return townRoot } // TestBeadsRoutingFromTownRoot verifies that bd show routes to correct rig // based on issue ID prefix when run from town root. func TestBeadsRoutingFromTownRoot(t *testing.T) { // Skip if bd is not available if _, err := exec.LookPath("bd"); err != nil { t.Skip("bd not installed, skipping routing test") } townRoot := setupRoutingTestTown(t) tests := []struct { prefix string expectedRig string // Expected rig path fragment in error/output }{ {"hq-", "."}, // Town-level beads {"gt-", "gastown"}, {"tr-", "testrig"}, } for _, tc := range tests { t.Run(tc.prefix, func(t *testing.T) { // Create a fake issue ID with the prefix issueID := tc.prefix + "test123" // Run bd show - it will fail since issue doesn't exist, // but we're testing routing, not the issue itself cmd := exec.Command("bd", "--no-daemon", "show", issueID) cmd.Dir = townRoot cmd.Env = append(os.Environ(), "BD_DEBUG_ROUTING=1") output, _ := cmd.CombinedOutput() // The debug routing output or error message should indicate // which beads directory was used outputStr := string(output) t.Logf("Output for %s: %s", issueID, outputStr) // We expect either the routing debug output or an error from the correct beads // If routing works, the error will be about not finding the issue, // not about routing failure if strings.Contains(outputStr, "no matching route") { t.Errorf("routing failed for prefix %s: %s", tc.prefix, outputStr) } }) } } // TestBeadsRedirectResolution verifies that redirect files are followed correctly. func TestBeadsRedirectResolution(t *testing.T) { townRoot := setupRoutingTestTown(t) tests := []struct { name string workDir string expected string // Expected resolved path (relative to townRoot) }{ { name: "polecat redirect", workDir: "gastown/polecats/rictus", expected: "gastown/mayor/rig/.beads", }, { name: "crew redirect", workDir: "gastown/crew/max", expected: "gastown/mayor/rig/.beads", }, { name: "no redirect (mayor/rig)", workDir: "gastown/mayor/rig", expected: "gastown/mayor/rig/.beads", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { fullWorkDir := filepath.Join(townRoot, tc.workDir) resolved := beads.ResolveBeadsDir(fullWorkDir) expectedFull := filepath.Join(townRoot, tc.expected) if resolved != expectedFull { t.Errorf("ResolveBeadsDir(%s) = %s, want %s", tc.workDir, resolved, expectedFull) } }) } } // TestBeadsCircularRedirectDetection verifies that circular redirects are detected. func TestBeadsCircularRedirectDetection(t *testing.T) { tmpDir := t.TempDir() // Create a beads directory with a redirect pointing to itself beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } // Create redirect file pointing to itself (circular) redirectContent := ".beads" // Points to current .beads (circular) if err := os.WriteFile(filepath.Join(beadsDir, "redirect"), []byte(redirectContent), 0644); err != nil { t.Fatalf("write redirect: %v", err) } // ResolveBeadsDir should detect the circular redirect and return the original resolved := beads.ResolveBeadsDir(tmpDir) if resolved != beadsDir { t.Errorf("expected circular redirect to return original beads dir, got %s", resolved) } // The redirect file should have been removed redirectPath := filepath.Join(beadsDir, "redirect") if _, err := os.Stat(redirectPath); !os.IsNotExist(err) { t.Error("circular redirect file should have been removed") } } // TestBeadsPrefixConflictDetection verifies that duplicate prefixes are detected. func TestBeadsPrefixConflictDetection(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } // Create routes with a duplicate prefix routes := []beads.Route{ {Prefix: "gt-", Path: "gastown/mayor/rig"}, {Prefix: "gt-", Path: "other/mayor/rig"}, // Duplicate! {Prefix: "bd-", Path: "beads/mayor/rig"}, } if err := beads.WriteRoutes(beadsDir, routes); err != nil { t.Fatalf("write routes: %v", err) } // FindConflictingPrefixes should detect the duplicate conflicts, err := beads.FindConflictingPrefixes(beadsDir) if err != nil { t.Fatalf("FindConflictingPrefixes: %v", err) } if len(conflicts) == 0 { t.Error("expected to find conflicts, got none") } if paths, ok := conflicts["gt-"]; !ok { t.Error("expected conflict for prefix 'gt-'") } else if len(paths) != 2 { t.Errorf("expected 2 conflicting paths for 'gt-', got %d", len(paths)) } } // TestBeadsListFromPolecatDirectory verifies that bd list works from polecat directories. func TestBeadsListFromPolecatDirectory(t *testing.T) { // Skip if bd is not available if _, err := exec.LookPath("bd"); err != nil { t.Skip("bd not installed, skipping test") } townRoot := setupRoutingTestTown(t) polecatDir := filepath.Join(townRoot, "gastown", "polecats", "rictus") // Initialize beads in mayor/rig so bd list can work mayorRigBeads := filepath.Join(townRoot, "gastown", "mayor", "rig", ".beads") // Create a minimal beads.db (or use bd init) // For now, just test that the redirect is followed cmd := exec.Command("bd", "--no-daemon", "list") cmd.Dir = polecatDir output, err := cmd.CombinedOutput() // We expect either success (empty list) or an error about missing db, // but NOT an error about missing .beads directory (since redirect should work) outputStr := string(output) t.Logf("bd list output: %s", outputStr) if err != nil { // Check it's not a "no .beads directory" error if strings.Contains(outputStr, "no .beads directory") { t.Errorf("redirect not followed: %s", outputStr) } // Check it's finding the right beads directory via redirect if strings.Contains(outputStr, "redirect") && !strings.Contains(outputStr, mayorRigBeads) { // This is okay - the redirect is being processed t.Logf("redirect detected in output (expected)") } } } // TestBeadsListFromCrewDirectory verifies that bd list works from crew directories. func TestBeadsListFromCrewDirectory(t *testing.T) { // Skip if bd is not available if _, err := exec.LookPath("bd"); err != nil { t.Skip("bd not installed, skipping test") } townRoot := setupRoutingTestTown(t) crewDir := filepath.Join(townRoot, "gastown", "crew", "max") cmd := exec.Command("bd", "--no-daemon", "list") cmd.Dir = crewDir output, err := cmd.CombinedOutput() outputStr := string(output) t.Logf("bd list output from crew: %s", outputStr) if err != nil { // Check it's not a "no .beads directory" error if strings.Contains(outputStr, "no .beads directory") { t.Errorf("redirect not followed for crew: %s", outputStr) } } } // TestBeadsRoutesLoading verifies that routes.jsonl is loaded correctly. func TestBeadsRoutesLoading(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } // Create routes.jsonl with various entries routesContent := `{"prefix": "hq-", "path": "."} {"prefix": "gt-", "path": "gastown/mayor/rig"} # Comment line should be ignored {"prefix": "bd-", "path": "beads/mayor/rig"} {"prefix": "tr-", "path": "testrig/mayor/rig"} ` if err := os.WriteFile(filepath.Join(beadsDir, "routes.jsonl"), []byte(routesContent), 0644); err != nil { t.Fatalf("write routes: %v", err) } routes, err := beads.LoadRoutes(beadsDir) if err != nil { t.Fatalf("LoadRoutes: %v", err) } if len(routes) != 4 { t.Errorf("expected 4 routes, got %d", len(routes)) } // Verify specific routes expectedPrefixes := map[string]string{ "hq-": ".", "gt-": "gastown/mayor/rig", "bd-": "beads/mayor/rig", "tr-": "testrig/mayor/rig", } for _, r := range routes { if expected, ok := expectedPrefixes[r.Prefix]; ok { if r.Path != expected { t.Errorf("route %s: path = %q, want %q", r.Prefix, r.Path, expected) } } else { t.Errorf("unexpected prefix: %s", r.Prefix) } } } // TestBeadsAppendRoute verifies that routes can be appended and updated. func TestBeadsAppendRoute(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } // Append first route route1 := beads.Route{Prefix: "gt-", Path: "gastown/mayor/rig"} if err := beads.AppendRoute(tmpDir, route1); err != nil { t.Fatalf("AppendRoute 1: %v", err) } // Append second route route2 := beads.Route{Prefix: "bd-", Path: "beads/mayor/rig"} if err := beads.AppendRoute(tmpDir, route2); err != nil { t.Fatalf("AppendRoute 2: %v", err) } // Verify both routes exist routes, err := beads.LoadRoutes(beadsDir) if err != nil { t.Fatalf("LoadRoutes: %v", err) } if len(routes) != 2 { t.Errorf("expected 2 routes, got %d", len(routes)) } // Update existing route (same prefix, different path) route1Updated := beads.Route{Prefix: "gt-", Path: "newpath/mayor/rig"} if err := beads.AppendRoute(tmpDir, route1Updated); err != nil { t.Fatalf("AppendRoute update: %v", err) } // Verify update routes, _ = beads.LoadRoutes(beadsDir) if len(routes) != 2 { t.Errorf("expected 2 routes after update, got %d", len(routes)) } for _, r := range routes { if r.Prefix == "gt-" && r.Path != "newpath/mayor/rig" { t.Errorf("route update failed: got path %q", r.Path) } } } // TestBeadsRemoveRoute verifies that routes can be removed. func TestBeadsRemoveRoute(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } // Create initial routes routes := []beads.Route{ {Prefix: "gt-", Path: "gastown/mayor/rig"}, {Prefix: "bd-", Path: "beads/mayor/rig"}, } if err := beads.WriteRoutes(beadsDir, routes); err != nil { t.Fatalf("WriteRoutes: %v", err) } // Remove one route if err := beads.RemoveRoute(tmpDir, "gt-"); err != nil { t.Fatalf("RemoveRoute: %v", err) } // Verify removal remaining, _ := beads.LoadRoutes(beadsDir) if len(remaining) != 1 { t.Errorf("expected 1 route after removal, got %d", len(remaining)) } if remaining[0].Prefix != "bd-" { t.Errorf("wrong route remaining: %s", remaining[0].Prefix) } } // TestSlingCrossRigRoutingResolution verifies that sling can resolve rig paths // for cross-rig bead hooking using ExtractPrefix and GetRigPathForPrefix. // This is the fix for https://github.com/steveyegge/gastown/issues/148 func TestSlingCrossRigRoutingResolution(t *testing.T) { townRoot := setupRoutingTestTown(t) tests := []struct { beadID string expectedPath string // Relative to townRoot, or "." for town-level }{ {"gt-mol-abc", "gastown/mayor/rig"}, {"tr-task-xyz", "testrig/mayor/rig"}, {"hq-cv-123", "."}, // Town-level beads } for _, tc := range tests { t.Run(tc.beadID, func(t *testing.T) { // Step 1: Extract prefix from bead ID prefix := beads.ExtractPrefix(tc.beadID) if prefix == "" { t.Fatalf("ExtractPrefix(%q) returned empty", tc.beadID) } // Step 2: Resolve rig path from prefix rigPath := beads.GetRigPathForPrefix(townRoot, prefix) if rigPath == "" { t.Fatalf("GetRigPathForPrefix(%q, %q) returned empty", townRoot, prefix) } // Step 3: Verify the path is correct var expectedFull string if tc.expectedPath == "." { expectedFull = townRoot } else { expectedFull = filepath.Join(townRoot, tc.expectedPath) } if rigPath != expectedFull { t.Errorf("GetRigPathForPrefix resolved to %q, want %q", rigPath, expectedFull) } // Step 4: Verify the .beads directory exists at that path beadsDir := filepath.Join(rigPath, ".beads") if _, err := os.Stat(beadsDir); os.IsNotExist(err) { t.Errorf(".beads directory doesn't exist at resolved path: %s", beadsDir) } }) } } // TestSlingCrossRigUnknownPrefix verifies behavior for unknown prefixes. func TestSlingCrossRigUnknownPrefix(t *testing.T) { townRoot := setupRoutingTestTown(t) // An unknown prefix should return empty string unknownBeadID := "xx-unknown-123" prefix := beads.ExtractPrefix(unknownBeadID) if prefix != "xx-" { t.Fatalf("ExtractPrefix(%q) = %q, want %q", unknownBeadID, prefix, "xx-") } rigPath := beads.GetRigPathForPrefix(townRoot, prefix) if rigPath != "" { t.Errorf("GetRigPathForPrefix for unknown prefix returned %q, want empty", rigPath) } } // TestBeadsGetPrefixForRig verifies prefix lookup by rig name. func TestBeadsGetPrefixForRig(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } // Create routes routes := []beads.Route{ {Prefix: "gt-", Path: "gastown/mayor/rig"}, {Prefix: "bd-", Path: "beads/mayor/rig"}, {Prefix: "hq-", Path: "."}, } if err := beads.WriteRoutes(beadsDir, routes); err != nil { t.Fatalf("WriteRoutes: %v", err) } tests := []struct { rigName string expected string }{ {"gastown", "gt"}, {"beads", "bd"}, {"unknown", "gt"}, // Default {"", "gt"}, // Empty -> default } for _, tc := range tests { t.Run(tc.rigName, func(t *testing.T) { result := beads.GetPrefixForRig(tmpDir, tc.rigName) if result != tc.expected { t.Errorf("GetPrefixForRig(%q) = %q, want %q", tc.rigName, result, tc.expected) } }) } }