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 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", "") 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" 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 if dir != wantDir { t.Fatalf("bd cook ran in %q, want %q (args: %q)", dir, wantDir, args) } 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)) } }