Files
gastown/internal/cmd/sling_test.go
Julian Knutsen 80af0547ea chore: fix build break (#483)
* fix(sling_test): update test for cook dir change

The cook command no longer needs database context and runs from cwd,
not the target rig directory. Update test to match this behavior
change from bd2a5ab5.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): skip tests requiring missing binaries, handle --allow-stale

- Add skipIfAgentBinaryMissing helper to skip tests when codex/gemini
  binaries aren't available in the test environment
- Update rig manager test stub to handle --allow-stale flag

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: julianknutsen <julianknutsen@users.noreply.github>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:13:35 -08:00

706 lines
19 KiB
Go

package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseWispIDFromJSON(t *testing.T) {
tests := []struct {
name string
json string
wantID string
wantErr bool
}{
{
name: "new_epic_id",
json: `{"new_epic_id":"gt-wisp-abc","created":7,"phase":"vapor"}`,
wantID: "gt-wisp-abc",
},
{
name: "root_id legacy",
json: `{"root_id":"gt-wisp-legacy"}`,
wantID: "gt-wisp-legacy",
},
{
name: "result_id forward compat",
json: `{"result_id":"gt-wisp-result"}`,
wantID: "gt-wisp-result",
},
{
name: "precedence prefers new_epic_id",
json: `{"root_id":"gt-wisp-legacy","new_epic_id":"gt-wisp-new"}`,
wantID: "gt-wisp-new",
},
{
name: "missing id keys",
json: `{"created":7,"phase":"vapor"}`,
wantErr: true,
},
{
name: "invalid JSON",
json: `{"new_epic_id":`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotID, err := parseWispIDFromJSON([]byte(tt.json))
if (err != nil) != tt.wantErr {
t.Fatalf("parseWispIDFromJSON() error = %v, wantErr %v", err, tt.wantErr)
}
if gotID != tt.wantID {
t.Fatalf("parseWispIDFromJSON() id = %q, want %q", gotID, tt.wantID)
}
})
}
}
func TestFormatTrackBeadID(t *testing.T) {
tests := []struct {
name string
beadID string
expected string
}{
// HQ beads should remain unchanged
{
name: "hq bead unchanged",
beadID: "hq-abc123",
expected: "hq-abc123",
},
{
name: "hq convoy unchanged",
beadID: "hq-cv-xyz789",
expected: "hq-cv-xyz789",
},
// Cross-rig beads get external: prefix
{
name: "gastown rig bead",
beadID: "gt-mol-abc123",
expected: "external:gt-mol:gt-mol-abc123",
},
{
name: "beads rig task",
beadID: "beads-task-xyz",
expected: "external:beads-task:beads-task-xyz",
},
{
name: "two segment ID",
beadID: "foo-bar",
expected: "external:foo-bar:foo-bar",
},
// Edge cases
{
name: "single segment fallback",
beadID: "orphan",
expected: "orphan",
},
{
name: "empty string fallback",
beadID: "",
expected: "",
},
{
name: "many segments",
beadID: "a-b-c-d-e-f",
expected: "external:a-b:a-b-c-d-e-f",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatTrackBeadID(tt.beadID)
if result != tt.expected {
t.Errorf("formatTrackBeadID(%q) = %q, want %q", tt.beadID, result, tt.expected)
}
})
}
}
// TestFormatTrackBeadIDConsumerCompatibility verifies that the external ref format
// produced by formatTrackBeadID can be correctly parsed by the consumer pattern
// used in convoy.go, model.go, feed/convoy.go, and web/fetcher.go.
func TestFormatTrackBeadIDConsumerCompatibility(t *testing.T) {
// Consumer pattern from convoy.go:1062-1068:
// if strings.HasPrefix(issueID, "external:") {
// parts := strings.SplitN(issueID, ":", 3)
// if len(parts) == 3 {
// issueID = parts[2] // Extract the actual issue ID
// }
// }
tests := []struct {
name string
beadID string
wantOriginalID string
}{
{
name: "cross-rig bead round-trips",
beadID: "gt-mol-abc123",
wantOriginalID: "gt-mol-abc123",
},
{
name: "beads rig bead round-trips",
beadID: "beads-task-xyz",
wantOriginalID: "beads-task-xyz",
},
{
name: "hq bead unchanged",
beadID: "hq-abc123",
wantOriginalID: "hq-abc123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
formatted := formatTrackBeadID(tt.beadID)
// Simulate consumer parsing logic
parsed := formatted
if len(formatted) > 9 && formatted[:9] == "external:" {
parts := make([]string, 0, 3)
start := 0
count := 0
for i := 0; i < len(formatted) && count < 2; i++ {
if formatted[i] == ':' {
parts = append(parts, formatted[start:i])
start = i + 1
count++
}
}
if count == 2 {
parts = append(parts, formatted[start:])
}
if len(parts) == 3 {
parsed = parts[2]
}
}
if parsed != tt.wantOriginalID {
t.Errorf("round-trip failed: formatTrackBeadID(%q) = %q, parsed back to %q, want %q",
tt.beadID, formatted, parsed, tt.wantOriginalID)
}
})
}
}
func TestSlingFormulaOnBeadRoutesBDCommandsToTargetRig(t *testing.T) {
townRoot := t.TempDir()
// Minimal workspace marker so workspace.FindFromCwd() succeeds.
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
// Create a rig path that owns gt-* beads, and a routes.jsonl pointing to it.
rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig")
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
t.Fatalf("mkdir .beads: %v", err)
}
if err := os.MkdirAll(rigDir, 0755); err != nil {
t.Fatalf("mkdir rigDir: %v", err)
}
routes := strings.Join([]string{
`{"prefix":"gt-","path":"gastown/mayor/rig"}`,
`{"prefix":"hq-","path":"."}`,
"",
}, "\n")
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil {
t.Fatalf("write routes.jsonl: %v", err)
}
// Stub bd so we can observe the working directory for cook/wisp/bond.
binDir := filepath.Join(townRoot, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatalf("mkdir binDir: %v", err)
}
logPath := filepath.Join(townRoot, "bd.log")
bdPath := filepath.Join(binDir, "bd")
bdScript := `#!/bin/sh
set -e
echo "$(pwd)|$*" >> "${BD_LOG}"
if [ "$1" = "--no-daemon" ]; then
shift
fi
cmd="$1"
shift || true
case "$cmd" in
show)
echo '[{"title":"Test issue","status":"open","assignee":"","description":""}]'
;;
formula)
# formula show <name> - must output something for verifyFormulaExists
echo '{"name":"test-formula"}'
exit 0
;;
cook)
exit 0
;;
mol)
sub="$1"
shift || true
case "$sub" in
wisp)
echo '{"new_epic_id":"gt-wisp-xyz"}'
;;
bond)
echo '{"root_id":"gt-wisp-xyz"}'
;;
esac
;;
esac
exit 0
`
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
t.Fatalf("write bd stub: %v", err)
}
t.Setenv("BD_LOG", logPath)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(EnvGTRole, "mayor")
t.Setenv("GT_POLECAT", "")
t.Setenv("GT_CREW", "")
t.Setenv("TMUX_PANE", "") // Prevent inheriting real tmux pane from test runner
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil {
t.Fatalf("chdir: %v", err)
}
// Ensure we don't leak global flag state across tests.
prevOn := slingOnTarget
prevVars := slingVars
prevDryRun := slingDryRun
prevNoConvoy := slingNoConvoy
t.Cleanup(func() {
slingOnTarget = prevOn
slingVars = prevVars
slingDryRun = prevDryRun
slingNoConvoy = prevNoConvoy
})
slingDryRun = false
slingNoConvoy = true
slingVars = nil
slingOnTarget = "gt-abc123"
// Prevent real tmux nudge from firing during tests (causes agent self-interruption)
t.Setenv("GT_TEST_NO_NUDGE", "1")
if err := runSling(nil, []string{"mol-review"}); err != nil {
t.Fatalf("runSling: %v", err)
}
logBytes, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read bd log: %v", err)
}
logLines := strings.Split(strings.TrimSpace(string(logBytes)), "\n")
wantDir := rigDir
if resolved, err := filepath.EvalSymlinks(wantDir); err == nil {
wantDir = resolved
}
gotCook := false
gotWisp := false
gotBond := false
for _, line := range logLines {
parts := strings.SplitN(line, "|", 2)
if len(parts) != 2 {
continue
}
dir := parts[0]
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
dir = resolved
}
args := parts[1]
switch {
case strings.Contains(args, " cook "):
gotCook = true
// cook doesn't need database context, runs from cwd
case strings.Contains(args, " mol wisp "):
gotWisp = true
if dir != wantDir {
t.Fatalf("bd mol wisp ran in %q, want %q (args: %q)", dir, wantDir, args)
}
case strings.Contains(args, " mol bond "):
gotBond = true
if dir != wantDir {
t.Fatalf("bd mol bond ran in %q, want %q (args: %q)", dir, wantDir, args)
}
}
}
if !gotCook || !gotWisp || !gotBond {
t.Fatalf("missing expected bd commands: cook=%v wisp=%v bond=%v (log: %q)", gotCook, gotWisp, gotBond, string(logBytes))
}
}
// TestSlingFormulaOnBeadPassesFeatureAndIssueVars verifies that when using
// gt sling <formula> --on <bead>, both --var feature=<title> and --var issue=<beadID>
// are passed to the bd mol wisp command.
func TestSlingFormulaOnBeadPassesFeatureAndIssueVars(t *testing.T) {
townRoot := t.TempDir()
// Minimal workspace marker so workspace.FindFromCwd() succeeds.
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
// Create a rig path that owns gt-* beads, and a routes.jsonl pointing to it.
rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig")
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
t.Fatalf("mkdir .beads: %v", err)
}
if err := os.MkdirAll(rigDir, 0755); err != nil {
t.Fatalf("mkdir rigDir: %v", err)
}
routes := strings.Join([]string{
`{"prefix":"gt-","path":"gastown/mayor/rig"}`,
`{"prefix":"hq-","path":"."}`,
"",
}, "\n")
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil {
t.Fatalf("write routes.jsonl: %v", err)
}
// Stub bd so we can observe the arguments passed to mol wisp.
binDir := filepath.Join(townRoot, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatalf("mkdir binDir: %v", err)
}
logPath := filepath.Join(townRoot, "bd.log")
bdPath := filepath.Join(binDir, "bd")
// The stub returns a specific title so we can verify it appears in --var feature=
bdScript := `#!/bin/sh
set -e
echo "ARGS:$*" >> "${BD_LOG}"
if [ "$1" = "--no-daemon" ]; then
shift
fi
cmd="$1"
shift || true
case "$cmd" in
show)
echo '[{"title":"My Test Feature","status":"open","assignee":"","description":""}]'
;;
formula)
# formula show <name> - must output something for verifyFormulaExists
echo '{"name":"mol-review"}'
exit 0
;;
cook)
exit 0
;;
mol)
sub="$1"
shift || true
case "$sub" in
wisp)
echo '{"new_epic_id":"gt-wisp-xyz"}'
;;
bond)
echo '{"root_id":"gt-wisp-xyz"}'
;;
esac
;;
esac
exit 0
`
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
t.Fatalf("write bd stub: %v", err)
}
t.Setenv("BD_LOG", logPath)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(EnvGTRole, "mayor")
t.Setenv("GT_POLECAT", "")
t.Setenv("GT_CREW", "")
t.Setenv("TMUX_PANE", "") // Prevent inheriting real tmux pane from test runner
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil {
t.Fatalf("chdir: %v", err)
}
// Ensure we don't leak global flag state across tests.
prevOn := slingOnTarget
prevVars := slingVars
prevDryRun := slingDryRun
prevNoConvoy := slingNoConvoy
t.Cleanup(func() {
slingOnTarget = prevOn
slingVars = prevVars
slingDryRun = prevDryRun
slingNoConvoy = prevNoConvoy
})
slingDryRun = false
slingNoConvoy = true
slingVars = nil
slingOnTarget = "gt-abc123"
// Prevent real tmux nudge from firing during tests (causes agent self-interruption)
t.Setenv("GT_TEST_NO_NUDGE", "1")
if err := runSling(nil, []string{"mol-review"}); err != nil {
t.Fatalf("runSling: %v", err)
}
logBytes, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read bd log: %v", err)
}
// Find the mol wisp command and verify both --var arguments
logLines := strings.Split(string(logBytes), "\n")
var wispLine string
for _, line := range logLines {
if strings.Contains(line, "mol wisp") {
wispLine = line
break
}
}
if wispLine == "" {
t.Fatalf("mol wisp command not found in log: %s", string(logBytes))
}
// Verify --var feature=<title> is present
if !strings.Contains(wispLine, "--var feature=My Test Feature") {
t.Errorf("mol wisp missing --var feature=<title>\ngot: %s", wispLine)
}
// Verify --var issue=<beadID> is present
if !strings.Contains(wispLine, "--var issue=gt-abc123") {
t.Errorf("mol wisp missing --var issue=<beadID>\ngot: %s", wispLine)
}
}
// TestVerifyBeadExistsAllowStale reproduces the bug in gtl-ncq where beads
// visible via regular bd show fail with --no-daemon due to database sync issues.
// The fix uses --allow-stale to skip the sync check for existence verification.
func TestVerifyBeadExistsAllowStale(t *testing.T) {
townRoot := t.TempDir()
// Create minimal workspace structure
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
// Create a stub bd that simulates the sync issue:
// - --no-daemon without --allow-stale fails (database out of sync)
// - --no-daemon with --allow-stale succeeds (skips sync check)
binDir := filepath.Join(townRoot, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatalf("mkdir binDir: %v", err)
}
bdPath := filepath.Join(binDir, "bd")
bdScript := `#!/bin/sh
# Check for --allow-stale flag
allow_stale=false
for arg in "$@"; do
if [ "$arg" = "--allow-stale" ]; then
allow_stale=true
fi
done
if [ "$1" = "--no-daemon" ]; then
if [ "$allow_stale" = "true" ]; then
# --allow-stale skips sync check, succeeds
echo '[{"title":"Test bead","status":"open","assignee":""}]'
exit 0
else
# Without --allow-stale, fails with sync error
echo '{"error":"Database out of sync with JSONL."}'
exit 1
fi
fi
# Daemon mode works
echo '[{"title":"Test bead","status":"open","assignee":""}]'
exit 0
`
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
t.Fatalf("write bd stub: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// EXPECTED: verifyBeadExists should use --no-daemon --allow-stale and succeed
beadID := "jv-v599"
err = verifyBeadExists(beadID)
if err != nil {
t.Errorf("verifyBeadExists(%q) failed: %v\nExpected --allow-stale to skip sync check", beadID, err)
}
}
// TestSlingWithAllowStale tests the full gt sling flow with --allow-stale fix.
// This is an integration test for the gtl-ncq bug.
func TestSlingWithAllowStale(t *testing.T) {
townRoot := t.TempDir()
// Create minimal workspace structure
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
// Create stub bd that respects --allow-stale
binDir := filepath.Join(townRoot, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatalf("mkdir binDir: %v", err)
}
bdPath := filepath.Join(binDir, "bd")
bdScript := `#!/bin/sh
# Check for --allow-stale flag
allow_stale=false
for arg in "$@"; do
if [ "$arg" = "--allow-stale" ]; then
allow_stale=true
fi
done
if [ "$1" = "--no-daemon" ]; then
shift
cmd="$1"
if [ "$cmd" = "show" ]; then
if [ "$allow_stale" = "true" ]; then
echo '[{"title":"Synced bead","status":"open","assignee":""}]'
exit 0
fi
echo '{"error":"Database out of sync"}'
exit 1
fi
exit 0
fi
cmd="$1"
shift || true
case "$cmd" in
show)
echo '[{"title":"Synced bead","status":"open","assignee":""}]'
;;
update)
exit 0
;;
esac
exit 0
`
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
t.Fatalf("write bd stub: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(EnvGTRole, "crew")
t.Setenv("GT_CREW", "jv")
t.Setenv("GT_POLECAT", "")
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.Chdir(townRoot); err != nil {
t.Fatalf("chdir: %v", err)
}
// Save and restore global flags
prevDryRun := slingDryRun
prevNoConvoy := slingNoConvoy
t.Cleanup(func() {
slingDryRun = prevDryRun
slingNoConvoy = prevNoConvoy
})
slingDryRun = true
slingNoConvoy = true
// EXPECTED: gt sling should use daemon mode and succeed
// ACTUAL: verifyBeadExists uses --no-daemon and fails with sync error
beadID := "jv-v599"
err = runSling(nil, []string{beadID})
if err != nil {
// Check if it's the specific error we're testing for
if strings.Contains(err.Error(), "is not a valid bead or formula") {
t.Errorf("gt sling failed to recognize bead %q: %v\nExpected to use daemon mode, but used --no-daemon which fails when DB out of sync", beadID, err)
} else {
// Some other error - might be expected in dry-run mode
t.Logf("gt sling returned error (may be expected in test): %v", err)
}
}
}
// TestLooksLikeBeadID tests the bead ID pattern recognition function.
// This ensures gt sling accepts bead IDs even when routing-based verification fails.
// Fixes: gt sling bd-ka761 failing with 'not a valid bead or formula'
//
// Note: looksLikeBeadID is a fallback check in sling. The actual sling flow is:
// 1. Try verifyBeadExists (routing-based lookup)
// 2. Try verifyFormulaExists (formula check)
// 3. Fall back to looksLikeBeadID pattern match
// So "mol-release" matches the pattern but won't be treated as bead in practice
// because it would be caught by formula verification first.
func TestLooksLikeBeadID(t *testing.T) {
tests := []struct {
input string
want bool
}{
// Valid bead IDs - should return true
{"gt-abc123", true},
{"bd-ka761", true},
{"hq-cv-abc", true},
{"ap-qtsup.16", true},
{"beads-xyz", true},
{"jv-v599", true},
{"gt-9e8s5", true},
{"hq-00gyg", true},
// Short prefixes that match pattern (but may be formulas in practice)
{"mol-release", true}, // 3-char prefix matches pattern (formula check runs first in sling)
{"mol-abc123", true}, // 3-char prefix matches pattern
// Non-bead strings - should return false
{"formula-name", false}, // "formula" is 7 chars (> 5)
{"mayor", false}, // no hyphen
{"gastown", false}, // no hyphen
{"deacon/dogs", false}, // contains slash
{"", false}, // empty
{"-abc", false}, // starts with hyphen
{"GT-abc", false}, // uppercase prefix
{"123-abc", false}, // numeric prefix
{"a-", false}, // nothing after hyphen
{"aaaaaa-b", false}, // prefix too long (6 chars)
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := looksLikeBeadID(tt.input)
if got != tt.want {
t.Errorf("looksLikeBeadID(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}