The TestIntegration test was flaky because it uses the real .beads directory and the SQLite database could be out of sync with the JSONL file (e.g., after git pull updates the JSONL but before the database is re-imported). The fix runs `bd sync --import-only` at the start of the test to ensure the database is synchronized before running the actual test operations. Fixes gt-5ww96 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1505 lines
38 KiB
Go
1505 lines
38 KiB
Go
package beads
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestNew verifies the constructor.
|
|
func TestNew(t *testing.T) {
|
|
b := New("/some/path")
|
|
if b == nil {
|
|
t.Fatal("New returned nil")
|
|
}
|
|
if b.workDir != "/some/path" {
|
|
t.Errorf("workDir = %q, want /some/path", b.workDir)
|
|
}
|
|
}
|
|
|
|
// TestListOptions verifies ListOptions defaults.
|
|
func TestListOptions(t *testing.T) {
|
|
opts := ListOptions{
|
|
Status: "open",
|
|
Type: "task",
|
|
Priority: 1,
|
|
}
|
|
if opts.Status != "open" {
|
|
t.Errorf("Status = %q, want open", opts.Status)
|
|
}
|
|
}
|
|
|
|
// TestCreateOptions verifies CreateOptions fields.
|
|
func TestCreateOptions(t *testing.T) {
|
|
opts := CreateOptions{
|
|
Title: "Test issue",
|
|
Type: "task",
|
|
Priority: 2,
|
|
Description: "A test description",
|
|
Parent: "gt-abc",
|
|
}
|
|
if opts.Title != "Test issue" {
|
|
t.Errorf("Title = %q, want 'Test issue'", opts.Title)
|
|
}
|
|
if opts.Parent != "gt-abc" {
|
|
t.Errorf("Parent = %q, want gt-abc", opts.Parent)
|
|
}
|
|
}
|
|
|
|
// TestUpdateOptions verifies UpdateOptions pointer fields.
|
|
func TestUpdateOptions(t *testing.T) {
|
|
status := "in_progress"
|
|
priority := 1
|
|
opts := UpdateOptions{
|
|
Status: &status,
|
|
Priority: &priority,
|
|
}
|
|
if *opts.Status != "in_progress" {
|
|
t.Errorf("Status = %q, want in_progress", *opts.Status)
|
|
}
|
|
if *opts.Priority != 1 {
|
|
t.Errorf("Priority = %d, want 1", *opts.Priority)
|
|
}
|
|
}
|
|
|
|
// TestIsBeadsRepo tests repository detection.
|
|
func TestIsBeadsRepo(t *testing.T) {
|
|
// Test with a non-beads directory
|
|
tmpDir, err := os.MkdirTemp("", "beads-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
b := New(tmpDir)
|
|
// This should return false since there's no .beads directory
|
|
// and bd list will fail
|
|
if b.IsBeadsRepo() {
|
|
// This might pass if bd handles missing .beads gracefully
|
|
t.Log("IsBeadsRepo returned true for non-beads directory (bd might initialize)")
|
|
}
|
|
}
|
|
|
|
// TestWrapError tests error wrapping.
|
|
func TestWrapError(t *testing.T) {
|
|
b := New("/test")
|
|
|
|
tests := []struct {
|
|
stderr string
|
|
wantErr error
|
|
wantNil bool
|
|
}{
|
|
{"not a beads repository", ErrNotARepo, false},
|
|
{"No .beads directory found", ErrNotARepo, false},
|
|
{".beads directory not found", ErrNotARepo, false},
|
|
{"sync conflict detected", ErrSyncConflict, false},
|
|
{"CONFLICT in file.md", ErrSyncConflict, false},
|
|
{"Issue not found: gt-xyz", ErrNotFound, false},
|
|
{"gt-xyz not found", ErrNotFound, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
err := b.wrapError(nil, tt.stderr, []string{"test"})
|
|
if tt.wantNil {
|
|
if err != nil {
|
|
t.Errorf("wrapError(%q) = %v, want nil", tt.stderr, err)
|
|
}
|
|
} else {
|
|
if err != tt.wantErr {
|
|
t.Errorf("wrapError(%q) = %v, want %v", tt.stderr, err, tt.wantErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Integration test that runs against real bd if available
|
|
func TestIntegration(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
// Find a beads repo (use current directory if it has .beads)
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Walk up to find .beads
|
|
dir := cwd
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, ".beads")); err == nil {
|
|
break
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
t.Skip("no .beads directory found in path")
|
|
}
|
|
dir = parent
|
|
}
|
|
|
|
b := New(dir)
|
|
|
|
// Sync database with JSONL before testing to avoid "Database out of sync" errors.
|
|
// This can happen when JSONL is updated (e.g., by git pull) but the SQLite database
|
|
// hasn't been imported yet. Running sync --import-only ensures we test against
|
|
// consistent data and prevents flaky test failures.
|
|
syncCmd := exec.Command("bd", "--no-daemon", "sync", "--import-only")
|
|
syncCmd.Dir = dir
|
|
if err := syncCmd.Run(); err != nil {
|
|
// If sync fails (e.g., no database exists), just log and continue
|
|
t.Logf("bd sync --import-only failed (may not have db): %v", err)
|
|
}
|
|
|
|
// Test List
|
|
t.Run("List", func(t *testing.T) {
|
|
issues, err := b.List(ListOptions{Status: "open"})
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
t.Logf("Found %d open issues", len(issues))
|
|
})
|
|
|
|
// Test Ready
|
|
t.Run("Ready", func(t *testing.T) {
|
|
issues, err := b.Ready()
|
|
if err != nil {
|
|
t.Fatalf("Ready failed: %v", err)
|
|
}
|
|
t.Logf("Found %d ready issues", len(issues))
|
|
})
|
|
|
|
// Test Blocked
|
|
t.Run("Blocked", func(t *testing.T) {
|
|
issues, err := b.Blocked()
|
|
if err != nil {
|
|
t.Fatalf("Blocked failed: %v", err)
|
|
}
|
|
t.Logf("Found %d blocked issues", len(issues))
|
|
})
|
|
|
|
// Test Show (if we have issues)
|
|
t.Run("Show", func(t *testing.T) {
|
|
issues, err := b.List(ListOptions{})
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
if len(issues) == 0 {
|
|
t.Skip("no issues to show")
|
|
}
|
|
|
|
issue, err := b.Show(issues[0].ID)
|
|
if err != nil {
|
|
t.Fatalf("Show(%s) failed: %v", issues[0].ID, err)
|
|
}
|
|
t.Logf("Showed issue: %s - %s", issue.ID, issue.Title)
|
|
})
|
|
}
|
|
|
|
// TestParseMRFields tests parsing MR fields from issue descriptions.
|
|
func TestParseMRFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
issue *Issue
|
|
wantNil bool
|
|
wantFields *MRFields
|
|
}{
|
|
{
|
|
name: "nil issue",
|
|
issue: nil,
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "empty description",
|
|
issue: &Issue{Description: ""},
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "no MR fields",
|
|
issue: &Issue{Description: "This is just plain text\nwith no field markers"},
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "all fields",
|
|
issue: &Issue{
|
|
Description: `branch: polecat/Nux/gt-xyz
|
|
target: main
|
|
source_issue: gt-xyz
|
|
worker: Nux
|
|
rig: gastown
|
|
merge_commit: abc123def
|
|
close_reason: merged`,
|
|
},
|
|
wantFields: &MRFields{
|
|
Branch: "polecat/Nux/gt-xyz",
|
|
Target: "main",
|
|
SourceIssue: "gt-xyz",
|
|
Worker: "Nux",
|
|
Rig: "gastown",
|
|
MergeCommit: "abc123def",
|
|
CloseReason: "merged",
|
|
},
|
|
},
|
|
{
|
|
name: "partial fields",
|
|
issue: &Issue{
|
|
Description: `branch: polecat/Toast/gt-abc
|
|
target: integration/gt-epic
|
|
source_issue: gt-abc
|
|
worker: Toast`,
|
|
},
|
|
wantFields: &MRFields{
|
|
Branch: "polecat/Toast/gt-abc",
|
|
Target: "integration/gt-epic",
|
|
SourceIssue: "gt-abc",
|
|
Worker: "Toast",
|
|
},
|
|
},
|
|
{
|
|
name: "mixed with prose",
|
|
issue: &Issue{
|
|
Description: `branch: polecat/Capable/gt-def
|
|
target: main
|
|
source_issue: gt-def
|
|
|
|
This MR fixes a critical bug in the authentication system.
|
|
Please review carefully.
|
|
|
|
worker: Capable
|
|
rig: wasteland`,
|
|
},
|
|
wantFields: &MRFields{
|
|
Branch: "polecat/Capable/gt-def",
|
|
Target: "main",
|
|
SourceIssue: "gt-def",
|
|
Worker: "Capable",
|
|
Rig: "wasteland",
|
|
},
|
|
},
|
|
{
|
|
name: "alternate key formats",
|
|
issue: &Issue{
|
|
Description: `branch: polecat/Max/gt-ghi
|
|
source-issue: gt-ghi
|
|
merge-commit: 789xyz`,
|
|
},
|
|
wantFields: &MRFields{
|
|
Branch: "polecat/Max/gt-ghi",
|
|
SourceIssue: "gt-ghi",
|
|
MergeCommit: "789xyz",
|
|
},
|
|
},
|
|
{
|
|
name: "case insensitive keys",
|
|
issue: &Issue{
|
|
Description: `Branch: polecat/Furiosa/gt-jkl
|
|
TARGET: main
|
|
Worker: Furiosa
|
|
RIG: gastown`,
|
|
},
|
|
wantFields: &MRFields{
|
|
Branch: "polecat/Furiosa/gt-jkl",
|
|
Target: "main",
|
|
Worker: "Furiosa",
|
|
Rig: "gastown",
|
|
},
|
|
},
|
|
{
|
|
name: "extra whitespace",
|
|
issue: &Issue{
|
|
Description: ` branch: polecat/Nux/gt-mno
|
|
target:main
|
|
worker: Nux `,
|
|
},
|
|
wantFields: &MRFields{
|
|
Branch: "polecat/Nux/gt-mno",
|
|
Target: "main",
|
|
Worker: "Nux",
|
|
},
|
|
},
|
|
{
|
|
name: "ignores empty values",
|
|
issue: &Issue{
|
|
Description: `branch: polecat/Nux/gt-pqr
|
|
target:
|
|
source_issue: gt-pqr`,
|
|
},
|
|
wantFields: &MRFields{
|
|
Branch: "polecat/Nux/gt-pqr",
|
|
SourceIssue: "gt-pqr",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
fields := ParseMRFields(tt.issue)
|
|
|
|
if tt.wantNil {
|
|
if fields != nil {
|
|
t.Errorf("ParseMRFields() = %+v, want nil", fields)
|
|
}
|
|
return
|
|
}
|
|
|
|
if fields == nil {
|
|
t.Fatal("ParseMRFields() = nil, want non-nil")
|
|
}
|
|
|
|
if fields.Branch != tt.wantFields.Branch {
|
|
t.Errorf("Branch = %q, want %q", fields.Branch, tt.wantFields.Branch)
|
|
}
|
|
if fields.Target != tt.wantFields.Target {
|
|
t.Errorf("Target = %q, want %q", fields.Target, tt.wantFields.Target)
|
|
}
|
|
if fields.SourceIssue != tt.wantFields.SourceIssue {
|
|
t.Errorf("SourceIssue = %q, want %q", fields.SourceIssue, tt.wantFields.SourceIssue)
|
|
}
|
|
if fields.Worker != tt.wantFields.Worker {
|
|
t.Errorf("Worker = %q, want %q", fields.Worker, tt.wantFields.Worker)
|
|
}
|
|
if fields.Rig != tt.wantFields.Rig {
|
|
t.Errorf("Rig = %q, want %q", fields.Rig, tt.wantFields.Rig)
|
|
}
|
|
if fields.MergeCommit != tt.wantFields.MergeCommit {
|
|
t.Errorf("MergeCommit = %q, want %q", fields.MergeCommit, tt.wantFields.MergeCommit)
|
|
}
|
|
if fields.CloseReason != tt.wantFields.CloseReason {
|
|
t.Errorf("CloseReason = %q, want %q", fields.CloseReason, tt.wantFields.CloseReason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFormatMRFields tests formatting MR fields to string.
|
|
func TestFormatMRFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fields *MRFields
|
|
want string
|
|
}{
|
|
{
|
|
name: "nil fields",
|
|
fields: nil,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty fields",
|
|
fields: &MRFields{},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "all fields",
|
|
fields: &MRFields{
|
|
Branch: "polecat/Nux/gt-xyz",
|
|
Target: "main",
|
|
SourceIssue: "gt-xyz",
|
|
Worker: "Nux",
|
|
Rig: "gastown",
|
|
MergeCommit: "abc123def",
|
|
CloseReason: "merged",
|
|
},
|
|
want: `branch: polecat/Nux/gt-xyz
|
|
target: main
|
|
source_issue: gt-xyz
|
|
worker: Nux
|
|
rig: gastown
|
|
merge_commit: abc123def
|
|
close_reason: merged`,
|
|
},
|
|
{
|
|
name: "partial fields",
|
|
fields: &MRFields{
|
|
Branch: "polecat/Toast/gt-abc",
|
|
Target: "main",
|
|
SourceIssue: "gt-abc",
|
|
Worker: "Toast",
|
|
},
|
|
want: `branch: polecat/Toast/gt-abc
|
|
target: main
|
|
source_issue: gt-abc
|
|
worker: Toast`,
|
|
},
|
|
{
|
|
name: "only close fields",
|
|
fields: &MRFields{
|
|
MergeCommit: "deadbeef",
|
|
CloseReason: "rejected",
|
|
},
|
|
want: `merge_commit: deadbeef
|
|
close_reason: rejected`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := FormatMRFields(tt.fields)
|
|
if got != tt.want {
|
|
t.Errorf("FormatMRFields() =\n%q\nwant\n%q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSetMRFields tests updating issue descriptions with MR fields.
|
|
func TestSetMRFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
issue *Issue
|
|
fields *MRFields
|
|
want string
|
|
}{
|
|
{
|
|
name: "nil issue",
|
|
issue: nil,
|
|
fields: &MRFields{
|
|
Branch: "polecat/Nux/gt-xyz",
|
|
Target: "main",
|
|
},
|
|
want: `branch: polecat/Nux/gt-xyz
|
|
target: main`,
|
|
},
|
|
{
|
|
name: "empty description",
|
|
issue: &Issue{Description: ""},
|
|
fields: &MRFields{
|
|
Branch: "polecat/Nux/gt-xyz",
|
|
Target: "main",
|
|
SourceIssue: "gt-xyz",
|
|
},
|
|
want: `branch: polecat/Nux/gt-xyz
|
|
target: main
|
|
source_issue: gt-xyz`,
|
|
},
|
|
{
|
|
name: "preserve prose content",
|
|
issue: &Issue{Description: "This is a description of the work.\n\nIt spans multiple lines."},
|
|
fields: &MRFields{
|
|
Branch: "polecat/Toast/gt-abc",
|
|
Worker: "Toast",
|
|
},
|
|
want: `branch: polecat/Toast/gt-abc
|
|
worker: Toast
|
|
|
|
This is a description of the work.
|
|
|
|
It spans multiple lines.`,
|
|
},
|
|
{
|
|
name: "replace existing fields",
|
|
issue: &Issue{
|
|
Description: `branch: polecat/Nux/gt-old
|
|
target: develop
|
|
source_issue: gt-old
|
|
worker: Nux
|
|
|
|
Some existing prose content.`,
|
|
},
|
|
fields: &MRFields{
|
|
Branch: "polecat/Nux/gt-new",
|
|
Target: "main",
|
|
SourceIssue: "gt-new",
|
|
Worker: "Nux",
|
|
MergeCommit: "abc123",
|
|
},
|
|
want: `branch: polecat/Nux/gt-new
|
|
target: main
|
|
source_issue: gt-new
|
|
worker: Nux
|
|
merge_commit: abc123
|
|
|
|
Some existing prose content.`,
|
|
},
|
|
{
|
|
name: "preserve non-MR key-value lines",
|
|
issue: &Issue{
|
|
Description: `branch: polecat/Capable/gt-def
|
|
custom_field: some value
|
|
author: someone
|
|
target: main`,
|
|
},
|
|
fields: &MRFields{
|
|
Branch: "polecat/Capable/gt-ghi",
|
|
Target: "integration/epic",
|
|
CloseReason: "merged",
|
|
},
|
|
want: `branch: polecat/Capable/gt-ghi
|
|
target: integration/epic
|
|
close_reason: merged
|
|
|
|
custom_field: some value
|
|
author: someone`,
|
|
},
|
|
{
|
|
name: "empty fields clears MR data",
|
|
issue: &Issue{Description: "branch: old\ntarget: old\n\nKeep this text."},
|
|
fields: &MRFields{},
|
|
want: "Keep this text.",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := SetMRFields(tt.issue, tt.fields)
|
|
if got != tt.want {
|
|
t.Errorf("SetMRFields() =\n%q\nwant\n%q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMRFieldsRoundTrip tests that parse/format round-trips correctly.
|
|
func TestMRFieldsRoundTrip(t *testing.T) {
|
|
original := &MRFields{
|
|
Branch: "polecat/Nux/gt-xyz",
|
|
Target: "main",
|
|
SourceIssue: "gt-xyz",
|
|
Worker: "Nux",
|
|
Rig: "gastown",
|
|
MergeCommit: "abc123def789",
|
|
CloseReason: "merged",
|
|
}
|
|
|
|
// Format to string
|
|
formatted := FormatMRFields(original)
|
|
|
|
// Parse back
|
|
issue := &Issue{Description: formatted}
|
|
parsed := ParseMRFields(issue)
|
|
|
|
if parsed == nil {
|
|
t.Fatal("round-trip parse returned nil")
|
|
}
|
|
|
|
if *parsed != *original {
|
|
t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original)
|
|
}
|
|
}
|
|
|
|
// TestParseMRFieldsFromDesignDoc tests the example from the design doc.
|
|
func TestParseMRFieldsFromDesignDoc(t *testing.T) {
|
|
// Example from docs/merge-queue-design.md
|
|
description := `branch: polecat/Nux/gt-xyz
|
|
target: main
|
|
source_issue: gt-xyz
|
|
worker: Nux
|
|
rig: gastown`
|
|
|
|
issue := &Issue{Description: description}
|
|
fields := ParseMRFields(issue)
|
|
|
|
if fields == nil {
|
|
t.Fatal("ParseMRFields returned nil for design doc example")
|
|
}
|
|
|
|
// Verify all fields match the design doc
|
|
if fields.Branch != "polecat/Nux/gt-xyz" {
|
|
t.Errorf("Branch = %q, want polecat/Nux/gt-xyz", fields.Branch)
|
|
}
|
|
if fields.Target != "main" {
|
|
t.Errorf("Target = %q, want main", fields.Target)
|
|
}
|
|
if fields.SourceIssue != "gt-xyz" {
|
|
t.Errorf("SourceIssue = %q, want gt-xyz", fields.SourceIssue)
|
|
}
|
|
if fields.Worker != "Nux" {
|
|
t.Errorf("Worker = %q, want Nux", fields.Worker)
|
|
}
|
|
if fields.Rig != "gastown" {
|
|
t.Errorf("Rig = %q, want gastown", fields.Rig)
|
|
}
|
|
}
|
|
|
|
// TestSetMRFieldsPreservesURL tests that URLs in prose are preserved.
|
|
func TestSetMRFieldsPreservesURL(t *testing.T) {
|
|
// URLs contain colons which could be confused with key: value
|
|
issue := &Issue{
|
|
Description: `branch: old-branch
|
|
Check out https://example.com/path for more info.
|
|
Also see http://localhost:8080/api`,
|
|
}
|
|
|
|
fields := &MRFields{
|
|
Branch: "new-branch",
|
|
Target: "main",
|
|
}
|
|
|
|
result := SetMRFields(issue, fields)
|
|
|
|
// URLs should be preserved
|
|
if !strings.Contains(result, "https://example.com/path") {
|
|
t.Error("HTTPS URL was not preserved")
|
|
}
|
|
if !strings.Contains(result, "http://localhost:8080/api") {
|
|
t.Error("HTTP URL was not preserved")
|
|
}
|
|
if !strings.Contains(result, "branch: new-branch") {
|
|
t.Error("branch field was not set")
|
|
}
|
|
}
|
|
|
|
// TestParseAttachmentFields tests parsing attachment fields from issue descriptions.
|
|
func TestParseAttachmentFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
issue *Issue
|
|
wantNil bool
|
|
wantFields *AttachmentFields
|
|
}{
|
|
{
|
|
name: "nil issue",
|
|
issue: nil,
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "empty description",
|
|
issue: &Issue{Description: ""},
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "no attachment fields",
|
|
issue: &Issue{Description: "This is just plain text\nwith no attachment markers"},
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "both fields",
|
|
issue: &Issue{
|
|
Description: `attached_molecule: mol-xyz
|
|
attached_at: 2025-12-21T15:30:00Z`,
|
|
},
|
|
wantFields: &AttachmentFields{
|
|
AttachedMolecule: "mol-xyz",
|
|
AttachedAt: "2025-12-21T15:30:00Z",
|
|
},
|
|
},
|
|
{
|
|
name: "only molecule",
|
|
issue: &Issue{
|
|
Description: `attached_molecule: mol-abc`,
|
|
},
|
|
wantFields: &AttachmentFields{
|
|
AttachedMolecule: "mol-abc",
|
|
},
|
|
},
|
|
{
|
|
name: "mixed with other content",
|
|
issue: &Issue{
|
|
Description: `attached_molecule: mol-def
|
|
attached_at: 2025-12-21T10:00:00Z
|
|
|
|
This is a handoff bead for the polecat.
|
|
Keep working on the current task.`,
|
|
},
|
|
wantFields: &AttachmentFields{
|
|
AttachedMolecule: "mol-def",
|
|
AttachedAt: "2025-12-21T10:00:00Z",
|
|
},
|
|
},
|
|
{
|
|
name: "alternate key formats (hyphen)",
|
|
issue: &Issue{
|
|
Description: `attached-molecule: mol-ghi
|
|
attached-at: 2025-12-21T12:00:00Z`,
|
|
},
|
|
wantFields: &AttachmentFields{
|
|
AttachedMolecule: "mol-ghi",
|
|
AttachedAt: "2025-12-21T12:00:00Z",
|
|
},
|
|
},
|
|
{
|
|
name: "case insensitive",
|
|
issue: &Issue{
|
|
Description: `Attached_Molecule: mol-jkl
|
|
ATTACHED_AT: 2025-12-21T14:00:00Z`,
|
|
},
|
|
wantFields: &AttachmentFields{
|
|
AttachedMolecule: "mol-jkl",
|
|
AttachedAt: "2025-12-21T14:00:00Z",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
fields := ParseAttachmentFields(tt.issue)
|
|
|
|
if tt.wantNil {
|
|
if fields != nil {
|
|
t.Errorf("ParseAttachmentFields() = %+v, want nil", fields)
|
|
}
|
|
return
|
|
}
|
|
|
|
if fields == nil {
|
|
t.Fatal("ParseAttachmentFields() = nil, want non-nil")
|
|
}
|
|
|
|
if fields.AttachedMolecule != tt.wantFields.AttachedMolecule {
|
|
t.Errorf("AttachedMolecule = %q, want %q", fields.AttachedMolecule, tt.wantFields.AttachedMolecule)
|
|
}
|
|
if fields.AttachedAt != tt.wantFields.AttachedAt {
|
|
t.Errorf("AttachedAt = %q, want %q", fields.AttachedAt, tt.wantFields.AttachedAt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFormatAttachmentFields tests formatting attachment fields to string.
|
|
func TestFormatAttachmentFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fields *AttachmentFields
|
|
want string
|
|
}{
|
|
{
|
|
name: "nil fields",
|
|
fields: nil,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty fields",
|
|
fields: &AttachmentFields{},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "both fields",
|
|
fields: &AttachmentFields{
|
|
AttachedMolecule: "mol-xyz",
|
|
AttachedAt: "2025-12-21T15:30:00Z",
|
|
},
|
|
want: `attached_molecule: mol-xyz
|
|
attached_at: 2025-12-21T15:30:00Z`,
|
|
},
|
|
{
|
|
name: "only molecule",
|
|
fields: &AttachmentFields{
|
|
AttachedMolecule: "mol-abc",
|
|
},
|
|
want: "attached_molecule: mol-abc",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := FormatAttachmentFields(tt.fields)
|
|
if got != tt.want {
|
|
t.Errorf("FormatAttachmentFields() =\n%q\nwant\n%q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSetAttachmentFields tests updating issue descriptions with attachment fields.
|
|
func TestSetAttachmentFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
issue *Issue
|
|
fields *AttachmentFields
|
|
want string
|
|
}{
|
|
{
|
|
name: "nil issue",
|
|
issue: nil,
|
|
fields: &AttachmentFields{
|
|
AttachedMolecule: "mol-xyz",
|
|
AttachedAt: "2025-12-21T15:30:00Z",
|
|
},
|
|
want: `attached_molecule: mol-xyz
|
|
attached_at: 2025-12-21T15:30:00Z`,
|
|
},
|
|
{
|
|
name: "empty description",
|
|
issue: &Issue{Description: ""},
|
|
fields: &AttachmentFields{
|
|
AttachedMolecule: "mol-abc",
|
|
AttachedAt: "2025-12-21T10:00:00Z",
|
|
},
|
|
want: `attached_molecule: mol-abc
|
|
attached_at: 2025-12-21T10:00:00Z`,
|
|
},
|
|
{
|
|
name: "preserve prose content",
|
|
issue: &Issue{Description: "This is a handoff bead description.\n\nKeep working on the task."},
|
|
fields: &AttachmentFields{
|
|
AttachedMolecule: "mol-def",
|
|
},
|
|
want: `attached_molecule: mol-def
|
|
|
|
This is a handoff bead description.
|
|
|
|
Keep working on the task.`,
|
|
},
|
|
{
|
|
name: "replace existing fields",
|
|
issue: &Issue{
|
|
Description: `attached_molecule: mol-old
|
|
attached_at: 2025-12-20T10:00:00Z
|
|
|
|
Some existing prose content.`,
|
|
},
|
|
fields: &AttachmentFields{
|
|
AttachedMolecule: "mol-new",
|
|
AttachedAt: "2025-12-21T15:30:00Z",
|
|
},
|
|
want: `attached_molecule: mol-new
|
|
attached_at: 2025-12-21T15:30:00Z
|
|
|
|
Some existing prose content.`,
|
|
},
|
|
{
|
|
name: "nil fields clears attachment",
|
|
issue: &Issue{Description: "attached_molecule: mol-old\nattached_at: 2025-12-20T10:00:00Z\n\nKeep this text."},
|
|
fields: nil,
|
|
want: "Keep this text.",
|
|
},
|
|
{
|
|
name: "empty fields clears attachment",
|
|
issue: &Issue{Description: "attached_molecule: mol-old\n\nKeep this text."},
|
|
fields: &AttachmentFields{},
|
|
want: "Keep this text.",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := SetAttachmentFields(tt.issue, tt.fields)
|
|
if got != tt.want {
|
|
t.Errorf("SetAttachmentFields() =\n%q\nwant\n%q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAttachmentFieldsRoundTrip tests that parse/format round-trips correctly.
|
|
func TestAttachmentFieldsRoundTrip(t *testing.T) {
|
|
original := &AttachmentFields{
|
|
AttachedMolecule: "mol-roundtrip",
|
|
AttachedAt: "2025-12-21T15:30:00Z",
|
|
}
|
|
|
|
// Format to string
|
|
formatted := FormatAttachmentFields(original)
|
|
|
|
// Parse back
|
|
issue := &Issue{Description: formatted}
|
|
parsed := ParseAttachmentFields(issue)
|
|
|
|
if parsed == nil {
|
|
t.Fatal("round-trip parse returned nil")
|
|
}
|
|
|
|
if *parsed != *original {
|
|
t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original)
|
|
}
|
|
}
|
|
|
|
// TestResolveBeadsDir tests the redirect following logic.
|
|
func TestResolveBeadsDir(t *testing.T) {
|
|
// Create temp directory structure
|
|
tmpDir, err := os.MkdirTemp("", "beads-redirect-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
t.Run("no redirect", func(t *testing.T) {
|
|
// Create a simple .beads directory without redirect
|
|
workDir := filepath.Join(tmpDir, "no-redirect")
|
|
beadsDir := filepath.Join(workDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := ResolveBeadsDir(workDir)
|
|
want := beadsDir
|
|
if got != want {
|
|
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("with redirect", func(t *testing.T) {
|
|
// Create structure like: crew/max/.beads/redirect -> ../../mayor/rig/.beads
|
|
workDir := filepath.Join(tmpDir, "crew", "max")
|
|
localBeadsDir := filepath.Join(workDir, ".beads")
|
|
targetBeadsDir := filepath.Join(tmpDir, "mayor", "rig", ".beads")
|
|
|
|
// Create both directories
|
|
if err := os.MkdirAll(localBeadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(targetBeadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create redirect file
|
|
redirectPath := filepath.Join(localBeadsDir, "redirect")
|
|
if err := os.WriteFile(redirectPath, []byte("../../mayor/rig/.beads\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := ResolveBeadsDir(workDir)
|
|
want := targetBeadsDir
|
|
if got != want {
|
|
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("no beads directory", func(t *testing.T) {
|
|
// Directory with no .beads at all
|
|
workDir := filepath.Join(tmpDir, "empty")
|
|
if err := os.MkdirAll(workDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := ResolveBeadsDir(workDir)
|
|
want := filepath.Join(workDir, ".beads")
|
|
if got != want {
|
|
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("empty redirect file", func(t *testing.T) {
|
|
// Redirect file exists but is empty - should fall back to local
|
|
workDir := filepath.Join(tmpDir, "empty-redirect")
|
|
beadsDir := filepath.Join(workDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
redirectPath := filepath.Join(beadsDir, "redirect")
|
|
if err := os.WriteFile(redirectPath, []byte(" \n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := ResolveBeadsDir(workDir)
|
|
want := beadsDir
|
|
if got != want {
|
|
t.Errorf("ResolveBeadsDir() = %q, want %q", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("circular redirect", func(t *testing.T) {
|
|
// Redirect that points to itself (e.g., mayor/rig/.beads/redirect -> ../../mayor/rig/.beads)
|
|
// This is the bug scenario from gt-csbjj
|
|
workDir := filepath.Join(tmpDir, "mayor", "rig")
|
|
beadsDir := filepath.Join(workDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create a circular redirect: ../../mayor/rig/.beads resolves back to .beads
|
|
redirectPath := filepath.Join(beadsDir, "redirect")
|
|
if err := os.WriteFile(redirectPath, []byte("../../mayor/rig/.beads\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// ResolveBeadsDir should detect the circular redirect and return the original beadsDir
|
|
got := ResolveBeadsDir(workDir)
|
|
want := beadsDir
|
|
if got != want {
|
|
t.Errorf("ResolveBeadsDir() = %q, want %q (should ignore circular redirect)", got, want)
|
|
}
|
|
|
|
// The circular redirect file should have been removed
|
|
if _, err := os.Stat(redirectPath); err == nil {
|
|
t.Error("circular redirect file should have been removed, but it still exists")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseAgentBeadID(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
wantRig string
|
|
wantRole string
|
|
wantName string
|
|
wantOK bool
|
|
}{
|
|
// Town-level agents
|
|
{"gt-mayor", "", "mayor", "", true},
|
|
{"gt-deacon", "", "deacon", "", true},
|
|
// Rig-level singletons
|
|
{"gt-gastown-witness", "gastown", "witness", "", true},
|
|
{"gt-gastown-refinery", "gastown", "refinery", "", true},
|
|
// Rig-level named agents
|
|
{"gt-gastown-crew-joe", "gastown", "crew", "joe", true},
|
|
{"gt-gastown-crew-max", "gastown", "crew", "max", true},
|
|
{"gt-gastown-polecat-capable", "gastown", "polecat", "capable", true},
|
|
// Names with hyphens
|
|
{"gt-gastown-polecat-my-agent", "gastown", "polecat", "my-agent", true},
|
|
// Parseable but not valid agent roles (IsAgentSessionBead will reject)
|
|
{"gt-abc123", "", "abc123", "", true}, // Parses as town-level but not valid role
|
|
// Other prefixes (bd-, hq-)
|
|
{"bd-mayor", "", "mayor", "", true}, // bd prefix town-level
|
|
{"bd-beads-witness", "beads", "witness", "", true}, // bd prefix rig-level singleton
|
|
{"bd-beads-polecat-pearl", "beads", "polecat", "pearl", true}, // bd prefix rig-level named
|
|
{"hq-mayor", "", "mayor", "", true}, // hq prefix town-level
|
|
// Truly invalid patterns
|
|
{"x-mayor", "", "", "", false}, // Prefix too short (1 char)
|
|
{"abcd-mayor", "", "", "", false}, // Prefix too long (4 chars)
|
|
{"", "", "", "", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
rig, role, name, ok := ParseAgentBeadID(tt.input)
|
|
if ok != tt.wantOK {
|
|
t.Errorf("ParseAgentBeadID(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
|
|
return
|
|
}
|
|
if rig != tt.wantRig {
|
|
t.Errorf("ParseAgentBeadID(%q) rig = %q, want %q", tt.input, rig, tt.wantRig)
|
|
}
|
|
if role != tt.wantRole {
|
|
t.Errorf("ParseAgentBeadID(%q) role = %q, want %q", tt.input, role, tt.wantRole)
|
|
}
|
|
if name != tt.wantName {
|
|
t.Errorf("ParseAgentBeadID(%q) name = %q, want %q", tt.input, name, tt.wantName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsAgentSessionBead(t *testing.T) {
|
|
tests := []struct {
|
|
beadID string
|
|
want bool
|
|
}{
|
|
// Agent session beads with gt- prefix (should return true)
|
|
{"gt-mayor", true},
|
|
{"gt-deacon", true},
|
|
{"gt-gastown-witness", true},
|
|
{"gt-gastown-refinery", true},
|
|
{"gt-gastown-crew-joe", true},
|
|
{"gt-gastown-polecat-capable", true},
|
|
// Agent session beads with bd- prefix (should return true)
|
|
{"bd-mayor", true},
|
|
{"bd-deacon", true},
|
|
{"bd-beads-witness", true},
|
|
{"bd-beads-refinery", true},
|
|
{"bd-beads-crew-joe", true},
|
|
{"bd-beads-polecat-pearl", true},
|
|
// Regular work beads (should return false)
|
|
{"gt-abc123", false},
|
|
{"gt-sb6m4", false},
|
|
{"gt-u7dxq", false},
|
|
{"bd-abc123", false},
|
|
// Invalid beads
|
|
{"", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.beadID, func(t *testing.T) {
|
|
got := IsAgentSessionBead(tt.beadID)
|
|
if got != tt.want {
|
|
t.Errorf("IsAgentSessionBead(%q) = %v, want %v", tt.beadID, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseRoleConfig tests parsing role configuration from descriptions.
|
|
func TestParseRoleConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
description string
|
|
wantNil bool
|
|
wantConfig *RoleConfig
|
|
}{
|
|
{
|
|
name: "empty description",
|
|
description: "",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "no role config fields",
|
|
description: "This is just plain text\nwith no role config fields",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "all fields",
|
|
description: `session_pattern: gt-{rig}-{name}
|
|
work_dir_pattern: {town}/{rig}/polecats/{name}
|
|
needs_pre_sync: true
|
|
start_command: exec claude --dangerously-skip-permissions
|
|
env_var: GT_ROLE=polecat
|
|
env_var: GT_RIG={rig}`,
|
|
wantConfig: &RoleConfig{
|
|
SessionPattern: "gt-{rig}-{name}",
|
|
WorkDirPattern: "{town}/{rig}/polecats/{name}",
|
|
NeedsPreSync: true,
|
|
StartCommand: "exec claude --dangerously-skip-permissions",
|
|
EnvVars: map[string]string{"GT_ROLE": "polecat", "GT_RIG": "{rig}"},
|
|
},
|
|
},
|
|
{
|
|
name: "partial fields",
|
|
description: `session_pattern: gt-mayor
|
|
work_dir_pattern: {town}`,
|
|
wantConfig: &RoleConfig{
|
|
SessionPattern: "gt-mayor",
|
|
WorkDirPattern: "{town}",
|
|
EnvVars: map[string]string{},
|
|
},
|
|
},
|
|
{
|
|
name: "mixed with prose",
|
|
description: `You are the Witness.
|
|
|
|
session_pattern: gt-{rig}-witness
|
|
work_dir_pattern: {town}/{rig}
|
|
needs_pre_sync: false
|
|
|
|
Your job is to monitor workers.`,
|
|
wantConfig: &RoleConfig{
|
|
SessionPattern: "gt-{rig}-witness",
|
|
WorkDirPattern: "{town}/{rig}",
|
|
NeedsPreSync: false,
|
|
EnvVars: map[string]string{},
|
|
},
|
|
},
|
|
{
|
|
name: "alternate key formats (hyphen)",
|
|
description: `session-pattern: gt-{rig}-{name}
|
|
work-dir-pattern: {town}/{rig}/polecats/{name}
|
|
needs-pre-sync: true`,
|
|
wantConfig: &RoleConfig{
|
|
SessionPattern: "gt-{rig}-{name}",
|
|
WorkDirPattern: "{town}/{rig}/polecats/{name}",
|
|
NeedsPreSync: true,
|
|
EnvVars: map[string]string{},
|
|
},
|
|
},
|
|
{
|
|
name: "case insensitive keys",
|
|
description: `SESSION_PATTERN: gt-mayor
|
|
Work_Dir_Pattern: {town}`,
|
|
wantConfig: &RoleConfig{
|
|
SessionPattern: "gt-mayor",
|
|
WorkDirPattern: "{town}",
|
|
EnvVars: map[string]string{},
|
|
},
|
|
},
|
|
{
|
|
name: "ignores null values",
|
|
description: `session_pattern: gt-{rig}-witness
|
|
work_dir_pattern: null
|
|
needs_pre_sync: false`,
|
|
wantConfig: &RoleConfig{
|
|
SessionPattern: "gt-{rig}-witness",
|
|
EnvVars: map[string]string{},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
config := ParseRoleConfig(tt.description)
|
|
|
|
if tt.wantNil {
|
|
if config != nil {
|
|
t.Errorf("ParseRoleConfig() = %+v, want nil", config)
|
|
}
|
|
return
|
|
}
|
|
|
|
if config == nil {
|
|
t.Fatal("ParseRoleConfig() = nil, want non-nil")
|
|
}
|
|
|
|
if config.SessionPattern != tt.wantConfig.SessionPattern {
|
|
t.Errorf("SessionPattern = %q, want %q", config.SessionPattern, tt.wantConfig.SessionPattern)
|
|
}
|
|
if config.WorkDirPattern != tt.wantConfig.WorkDirPattern {
|
|
t.Errorf("WorkDirPattern = %q, want %q", config.WorkDirPattern, tt.wantConfig.WorkDirPattern)
|
|
}
|
|
if config.NeedsPreSync != tt.wantConfig.NeedsPreSync {
|
|
t.Errorf("NeedsPreSync = %v, want %v", config.NeedsPreSync, tt.wantConfig.NeedsPreSync)
|
|
}
|
|
if config.StartCommand != tt.wantConfig.StartCommand {
|
|
t.Errorf("StartCommand = %q, want %q", config.StartCommand, tt.wantConfig.StartCommand)
|
|
}
|
|
if len(config.EnvVars) != len(tt.wantConfig.EnvVars) {
|
|
t.Errorf("EnvVars len = %d, want %d", len(config.EnvVars), len(tt.wantConfig.EnvVars))
|
|
}
|
|
for k, v := range tt.wantConfig.EnvVars {
|
|
if config.EnvVars[k] != v {
|
|
t.Errorf("EnvVars[%q] = %q, want %q", k, config.EnvVars[k], v)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExpandRolePattern tests pattern expansion with placeholders.
|
|
func TestExpandRolePattern(t *testing.T) {
|
|
tests := []struct {
|
|
pattern string
|
|
townRoot string
|
|
rig string
|
|
name string
|
|
role string
|
|
want string
|
|
}{
|
|
{
|
|
pattern: "gt-mayor",
|
|
townRoot: "/Users/stevey/gt",
|
|
want: "gt-mayor",
|
|
},
|
|
{
|
|
pattern: "gt-{rig}-{role}",
|
|
townRoot: "/Users/stevey/gt",
|
|
rig: "gastown",
|
|
role: "witness",
|
|
want: "gt-gastown-witness",
|
|
},
|
|
{
|
|
pattern: "gt-{rig}-{name}",
|
|
townRoot: "/Users/stevey/gt",
|
|
rig: "gastown",
|
|
name: "toast",
|
|
want: "gt-gastown-toast",
|
|
},
|
|
{
|
|
pattern: "{town}/{rig}/polecats/{name}",
|
|
townRoot: "/Users/stevey/gt",
|
|
rig: "gastown",
|
|
name: "toast",
|
|
want: "/Users/stevey/gt/gastown/polecats/toast",
|
|
},
|
|
{
|
|
pattern: "{town}/{rig}/refinery/rig",
|
|
townRoot: "/Users/stevey/gt",
|
|
rig: "gastown",
|
|
want: "/Users/stevey/gt/gastown/refinery/rig",
|
|
},
|
|
{
|
|
pattern: "export GT_ROLE={role} GT_RIG={rig} BD_ACTOR={rig}/polecats/{name}",
|
|
townRoot: "/Users/stevey/gt",
|
|
rig: "gastown",
|
|
name: "toast",
|
|
role: "polecat",
|
|
want: "export GT_ROLE=polecat GT_RIG=gastown BD_ACTOR=gastown/polecats/toast",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.pattern, func(t *testing.T) {
|
|
got := ExpandRolePattern(tt.pattern, tt.townRoot, tt.rig, tt.name, tt.role)
|
|
if got != tt.want {
|
|
t.Errorf("ExpandRolePattern() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFormatRoleConfig tests formatting role config to string.
|
|
func TestFormatRoleConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config *RoleConfig
|
|
want string
|
|
}{
|
|
{
|
|
name: "nil config",
|
|
config: nil,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty config",
|
|
config: &RoleConfig{EnvVars: map[string]string{}},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "all fields",
|
|
config: &RoleConfig{
|
|
SessionPattern: "gt-{rig}-{name}",
|
|
WorkDirPattern: "{town}/{rig}/polecats/{name}",
|
|
NeedsPreSync: true,
|
|
StartCommand: "exec claude",
|
|
EnvVars: map[string]string{},
|
|
},
|
|
want: `session_pattern: gt-{rig}-{name}
|
|
work_dir_pattern: {town}/{rig}/polecats/{name}
|
|
needs_pre_sync: true
|
|
start_command: exec claude`,
|
|
},
|
|
{
|
|
name: "only session pattern",
|
|
config: &RoleConfig{
|
|
SessionPattern: "gt-mayor",
|
|
EnvVars: map[string]string{},
|
|
},
|
|
want: "session_pattern: gt-mayor",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := FormatRoleConfig(tt.config)
|
|
if got != tt.want {
|
|
t.Errorf("FormatRoleConfig() =\n%q\nwant\n%q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRoleConfigRoundTrip tests that parse/format round-trips correctly.
|
|
func TestRoleConfigRoundTrip(t *testing.T) {
|
|
original := &RoleConfig{
|
|
SessionPattern: "gt-{rig}-{name}",
|
|
WorkDirPattern: "{town}/{rig}/polecats/{name}",
|
|
NeedsPreSync: true,
|
|
StartCommand: "exec claude --dangerously-skip-permissions",
|
|
EnvVars: map[string]string{}, // Can't round-trip env vars due to order
|
|
}
|
|
|
|
// Format to string
|
|
formatted := FormatRoleConfig(original)
|
|
|
|
// Parse back
|
|
parsed := ParseRoleConfig(formatted)
|
|
|
|
if parsed == nil {
|
|
t.Fatal("round-trip parse returned nil")
|
|
}
|
|
|
|
if parsed.SessionPattern != original.SessionPattern {
|
|
t.Errorf("round-trip SessionPattern = %q, want %q", parsed.SessionPattern, original.SessionPattern)
|
|
}
|
|
if parsed.WorkDirPattern != original.WorkDirPattern {
|
|
t.Errorf("round-trip WorkDirPattern = %q, want %q", parsed.WorkDirPattern, original.WorkDirPattern)
|
|
}
|
|
if parsed.NeedsPreSync != original.NeedsPreSync {
|
|
t.Errorf("round-trip NeedsPreSync = %v, want %v", parsed.NeedsPreSync, original.NeedsPreSync)
|
|
}
|
|
if parsed.StartCommand != original.StartCommand {
|
|
t.Errorf("round-trip StartCommand = %q, want %q", parsed.StartCommand, original.StartCommand)
|
|
}
|
|
}
|
|
|
|
// TestRoleBeadID tests role bead ID generation.
|
|
func TestRoleBeadID(t *testing.T) {
|
|
tests := []struct {
|
|
roleType string
|
|
want string
|
|
}{
|
|
{"mayor", "gt-mayor-role"},
|
|
{"deacon", "gt-deacon-role"},
|
|
{"witness", "gt-witness-role"},
|
|
{"refinery", "gt-refinery-role"},
|
|
{"crew", "gt-crew-role"},
|
|
{"polecat", "gt-polecat-role"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.roleType, func(t *testing.T) {
|
|
got := RoleBeadID(tt.roleType)
|
|
if got != tt.want {
|
|
t.Errorf("RoleBeadID(%q) = %q, want %q", tt.roleType, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDelegationStruct tests the Delegation struct serialization.
|
|
func TestDelegationStruct(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
delegation Delegation
|
|
wantJSON string
|
|
}{
|
|
{
|
|
name: "full delegation",
|
|
delegation: Delegation{
|
|
Parent: "hop://accenture.com/eng/proj-123/task-a",
|
|
Child: "hop://alice@example.com/main-town/gastown/gt-xyz",
|
|
DelegatedBy: "hop://accenture.com",
|
|
DelegatedTo: "hop://alice@example.com",
|
|
Terms: &DelegationTerms{
|
|
Portion: "backend-api",
|
|
Deadline: "2025-06-01",
|
|
CreditShare: 80,
|
|
},
|
|
CreatedAt: "2025-01-15T10:00:00Z",
|
|
},
|
|
wantJSON: `{"parent":"hop://accenture.com/eng/proj-123/task-a","child":"hop://alice@example.com/main-town/gastown/gt-xyz","delegated_by":"hop://accenture.com","delegated_to":"hop://alice@example.com","terms":{"portion":"backend-api","deadline":"2025-06-01","credit_share":80},"created_at":"2025-01-15T10:00:00Z"}`,
|
|
},
|
|
{
|
|
name: "minimal delegation",
|
|
delegation: Delegation{
|
|
Parent: "gt-abc",
|
|
Child: "gt-xyz",
|
|
DelegatedBy: "steve",
|
|
DelegatedTo: "alice",
|
|
},
|
|
wantJSON: `{"parent":"gt-abc","child":"gt-xyz","delegated_by":"steve","delegated_to":"alice"}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := json.Marshal(tt.delegation)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal failed: %v", err)
|
|
}
|
|
if string(got) != tt.wantJSON {
|
|
t.Errorf("json.Marshal = %s, want %s", string(got), tt.wantJSON)
|
|
}
|
|
|
|
// Test round-trip
|
|
var parsed Delegation
|
|
if err := json.Unmarshal(got, &parsed); err != nil {
|
|
t.Fatalf("json.Unmarshal failed: %v", err)
|
|
}
|
|
if parsed.Parent != tt.delegation.Parent {
|
|
t.Errorf("parsed.Parent = %s, want %s", parsed.Parent, tt.delegation.Parent)
|
|
}
|
|
if parsed.Child != tt.delegation.Child {
|
|
t.Errorf("parsed.Child = %s, want %s", parsed.Child, tt.delegation.Child)
|
|
}
|
|
if parsed.DelegatedBy != tt.delegation.DelegatedBy {
|
|
t.Errorf("parsed.DelegatedBy = %s, want %s", parsed.DelegatedBy, tt.delegation.DelegatedBy)
|
|
}
|
|
if parsed.DelegatedTo != tt.delegation.DelegatedTo {
|
|
t.Errorf("parsed.DelegatedTo = %s, want %s", parsed.DelegatedTo, tt.delegation.DelegatedTo)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDelegationTerms tests the DelegationTerms struct.
|
|
func TestDelegationTerms(t *testing.T) {
|
|
terms := &DelegationTerms{
|
|
Portion: "frontend",
|
|
Deadline: "2025-03-15",
|
|
AcceptanceCriteria: "All tests passing, code reviewed",
|
|
CreditShare: 70,
|
|
}
|
|
|
|
got, err := json.Marshal(terms)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal failed: %v", err)
|
|
}
|
|
|
|
var parsed DelegationTerms
|
|
if err := json.Unmarshal(got, &parsed); err != nil {
|
|
t.Fatalf("json.Unmarshal failed: %v", err)
|
|
}
|
|
|
|
if parsed.Portion != terms.Portion {
|
|
t.Errorf("parsed.Portion = %s, want %s", parsed.Portion, terms.Portion)
|
|
}
|
|
if parsed.Deadline != terms.Deadline {
|
|
t.Errorf("parsed.Deadline = %s, want %s", parsed.Deadline, terms.Deadline)
|
|
}
|
|
if parsed.AcceptanceCriteria != terms.AcceptanceCriteria {
|
|
t.Errorf("parsed.AcceptanceCriteria = %s, want %s", parsed.AcceptanceCriteria, terms.AcceptanceCriteria)
|
|
}
|
|
if parsed.CreditShare != terms.CreditShare {
|
|
t.Errorf("parsed.CreditShare = %d, want %d", parsed.CreditShare, terms.CreditShare)
|
|
}
|
|
}
|