package main import ( "database/sql" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/steveyegge/beads/cmd/bd/doctor" "github.com/steveyegge/beads/internal/git" ) func TestDoctorNoBeadsDir(t *testing.T) { // Create temporary directory tmpDir := t.TempDir() // Run diagnostics result := runDiagnostics(tmpDir) // Should fail overall if result.OverallOK { t.Error("Expected OverallOK to be false when .beads/ directory is missing") } // Check installation check failed if len(result.Checks) == 0 { t.Fatal("Expected at least one check") } installCheck := result.Checks[0] if installCheck.Name != "Installation" { t.Errorf("Expected first check to be Installation, got %s", installCheck.Name) } if installCheck.Status != "error" { t.Errorf("Expected Installation status to be error, got %s", installCheck.Status) } if installCheck.Fix == "" { t.Error("Expected Installation check to have a fix") } } func TestDoctorWithBeadsDir(t *testing.T) { // Create temporary directory with .beads tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } // Run diagnostics result := runDiagnostics(tmpDir) // Should have installation check passing if len(result.Checks) == 0 { t.Fatal("Expected at least one check") } installCheck := result.Checks[0] if installCheck.Name != "Installation" { t.Errorf("Expected first check to be Installation, got %s", installCheck.Name) } if installCheck.Status != "ok" { t.Errorf("Expected Installation status to be ok, got %s", installCheck.Status) } } func TestDoctorJSONOutput(t *testing.T) { // Create temporary directory tmpDir := t.TempDir() // Run diagnostics result := runDiagnostics(tmpDir) // Marshal to JSON to verify structure jsonBytes, err := json.Marshal(result) if err != nil { t.Fatalf("Failed to marshal result to JSON: %v", err) } // Unmarshal back to verify structure var decoded doctorResult if err := json.Unmarshal(jsonBytes, &decoded); err != nil { t.Fatalf("Failed to unmarshal JSON: %v", err) } // Verify key fields if decoded.Path != result.Path { t.Errorf("Path mismatch: %s != %s", decoded.Path, result.Path) } if decoded.CLIVersion != result.CLIVersion { t.Errorf("CLIVersion mismatch: %s != %s", decoded.CLIVersion, result.CLIVersion) } if decoded.OverallOK != result.OverallOK { t.Errorf("OverallOK mismatch: %v != %v", decoded.OverallOK, result.OverallOK) } if len(decoded.Checks) != len(result.Checks) { t.Errorf("Checks length mismatch: %d != %d", len(decoded.Checks), len(result.Checks)) } } // Note: isHashID is tested in migrate_hash_ids_test.go func TestDetectHashBasedIDs(t *testing.T) { tests := []struct { name string sampleIDs []string hasTable bool expected bool }{ { name: "hash IDs with letters", sampleIDs: []string{"bd-a3f8e9", "bd-b2c4d6"}, hasTable: false, expected: true, }, { name: "hash IDs with mixed alphanumeric", sampleIDs: []string{"bd-0134cc5a", "bd-abc123"}, hasTable: false, expected: true, }, { name: "hash IDs all numeric with variable length", sampleIDs: []string{"bd-0088", "bd-0134cc5a", "bd-02a4"}, hasTable: false, expected: true, // Variable length indicates hash IDs }, { name: "hash IDs with leading zeros", sampleIDs: []string{"bd-0088", "bd-02a4", "bd-05a1"}, hasTable: false, expected: true, // Leading zeros indicate hash IDs }, { name: "hash IDs all numeric non-sequential", sampleIDs: []string{"bd-0088", "bd-2312", "bd-0458"}, hasTable: false, expected: true, // Non-sequential pattern }, { name: "sequential IDs", sampleIDs: []string{"bd-1", "bd-2", "bd-3", "bd-4"}, hasTable: false, expected: false, // Sequential pattern }, { name: "sequential IDs with gaps", sampleIDs: []string{"bd-1", "bd-5", "bd-10", "bd-15"}, hasTable: false, expected: false, // Still sequential pattern (small gaps allowed) }, { name: "database with child_counters table", sampleIDs: []string{"bd-1", "bd-2"}, hasTable: true, expected: true, // child_counters table indicates hash IDs }, { name: "hash IDs with hierarchical children", sampleIDs: []string{"bd-a3f8e9.1", "bd-a3f8e9.2", "bd-b2c4d6"}, hasTable: false, expected: true, // Base IDs have letters }, { name: "edge case: single ID with letters", sampleIDs: []string{"bd-abc"}, hasTable: false, expected: true, }, { name: "edge case: single sequential ID", sampleIDs: []string{"bd-1"}, hasTable: false, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create temporary database tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") // Open database and create schema db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatalf("Failed to open database: %v", err) } defer db.Close() // Create issues table _, err = db.Exec(` CREATE TABLE IF NOT EXISTS issues ( id TEXT PRIMARY KEY, title TEXT, created_at TIMESTAMP ) `) if err != nil { t.Fatalf("Failed to create issues table: %v", err) } // Create child_counters table if test requires it if tt.hasTable { _, err = db.Exec(` CREATE TABLE IF NOT EXISTS child_counters ( parent_id TEXT PRIMARY KEY, last_child INTEGER NOT NULL DEFAULT 0 ) `) if err != nil { t.Fatalf("Failed to create child_counters table: %v", err) } } // Insert sample issues for _, id := range tt.sampleIDs { _, err = db.Exec("INSERT INTO issues (id, title, created_at) VALUES (?, ?, datetime('now'))", id, "Test issue") if err != nil { t.Fatalf("Failed to insert issue %s: %v", id, err) } } // Test detection result := doctor.DetectHashBasedIDs(db, tt.sampleIDs) if result != tt.expected { t.Errorf("detectHashBasedIDs() = %v, want %v", result, tt.expected) } }) } } func TestCheckIDFormat(t *testing.T) { tests := []struct { name string issueIDs []string createTable bool // create child_counters table expectedStatus string }{ { name: "hash IDs with letters", issueIDs: []string{"bd-a3f8e9", "bd-b2c4d6", "bd-xyz123"}, createTable: false, expectedStatus: doctor.StatusOK, }, { name: "hash IDs all numeric with leading zeros", issueIDs: []string{"bd-0088", "bd-02a4", "bd-05a1", "bd-0458"}, createTable: false, expectedStatus: doctor.StatusOK, }, { name: "hash IDs with child_counters table", issueIDs: []string{"bd-123", "bd-456"}, createTable: true, expectedStatus: doctor.StatusOK, }, { name: "sequential IDs", issueIDs: []string{"bd-1", "bd-2", "bd-3", "bd-4"}, createTable: false, expectedStatus: doctor.StatusWarning, }, { name: "mixed: mostly hash IDs", issueIDs: []string{"bd-0088", "bd-0134cc5a", "bd-02a4"}, createTable: false, expectedStatus: doctor.StatusOK, // Variable length = hash IDs }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create temporary workspace tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } // Create database dbPath := filepath.Join(beadsDir, "beads.db") db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatalf("Failed to open database: %v", err) } defer db.Close() // Create schema _, err = db.Exec(` CREATE TABLE IF NOT EXISTS issues ( id TEXT PRIMARY KEY, title TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `) if err != nil { t.Fatalf("Failed to create issues table: %v", err) } if tt.createTable { _, err = db.Exec(` CREATE TABLE IF NOT EXISTS child_counters ( parent_id TEXT PRIMARY KEY, last_child INTEGER NOT NULL DEFAULT 0 ) `) if err != nil { t.Fatalf("Failed to create child_counters table: %v", err) } } // Insert test issues for i, id := range tt.issueIDs { _, err = db.Exec( "INSERT INTO issues (id, title, created_at) VALUES (?, ?, datetime('now', ?||' seconds'))", id, "Test issue "+id, fmt.Sprintf("+%d", i)) if err != nil { t.Fatalf("Failed to insert issue %s: %v", id, err) } } db.Close() // Run check check := doctor.CheckIDFormat(tmpDir) if check.Status != tt.expectedStatus { t.Errorf("Expected status %s, got %s (message: %s)", tt.expectedStatus, check.Status, check.Message) } if tt.expectedStatus == doctor.StatusOK && check.Status == doctor.StatusOK { if !strings.Contains(check.Message, "hash-based") { t.Errorf("Expected hash-based message, got: %s", check.Message) } } if tt.expectedStatus == doctor.StatusWarning && check.Status == doctor.StatusWarning { if check.Fix == "" { t.Error("Expected fix message for sequential IDs") } } }) } } func TestCheckInstallation(t *testing.T) { // Test with missing .beads directory tmpDir := t.TempDir() check := doctor.CheckInstallation(tmpDir) if check.Status != doctor.StatusError { t.Errorf("Expected error status, got %s", check.Status) } if check.Fix == "" { t.Error("Expected fix to be provided") } // Test with existing .beads directory beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } check = doctor.CheckInstallation(tmpDir) if check.Status != doctor.StatusOK { t.Errorf("Expected ok status, got %s", check.Status) } } func TestCheckDatabaseVersionJSONLMode(t *testing.T) { // Create temporary directory with .beads but no database tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } // Create empty issues.jsonl to simulate --no-db mode jsonlPath := filepath.Join(beadsDir, "issues.jsonl") if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil { t.Fatal(err) } // Create config.yaml with no-db: true to indicate intentional JSONL-only mode // Without this, doctor treats it as a fresh clone needing 'bd init' (bd-4ew) configPath := filepath.Join(beadsDir, "config.yaml") if err := os.WriteFile(configPath, []byte("no-db: true\n"), 0644); err != nil { t.Fatal(err) } check := doctor.CheckDatabaseVersion(tmpDir, Version) if check.Status != doctor.StatusOK { t.Errorf("Expected ok status for JSONL mode, got %s", check.Status) } if check.Message != "JSONL-only mode" { t.Errorf("Expected JSONL-only mode message, got %s", check.Message) } if check.Detail == "" { t.Error("Expected detail field to be set for JSONL mode") } } func TestCheckDatabaseVersionFreshClone(t *testing.T) { // Create temporary directory with .beads and JSONL but no database // This simulates a fresh clone that needs 'bd init' tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } // Create issues.jsonl with an issue (no config.yaml = not no-db mode) jsonlPath := filepath.Join(beadsDir, "issues.jsonl") if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Test"}`+"\n"), 0644); err != nil { t.Fatal(err) } check := doctor.CheckDatabaseVersion(tmpDir, Version) if check.Status != doctor.StatusWarning { t.Errorf("Expected warning status for fresh clone, got %s", check.Status) } if check.Message != "Fresh clone detected (no database)" { t.Errorf("Expected fresh clone message, got %s", check.Message) } if check.Fix == "" { t.Error("Expected fix field to recommend 'bd init'") } } func TestCompareVersions(t *testing.T) { tests := []struct { v1 string v2 string expected int }{ {"0.20.1", "0.20.1", 0}, // Equal {"0.20.1", "0.20.0", 1}, // v1 > v2 {"0.20.0", "0.20.1", -1}, // v1 < v2 {"0.10.0", "0.9.9", 1}, // Major.minor comparison {"1.0.0", "0.99.99", 1}, // Major version difference {"0.20.1", "0.3.0", 1}, // String comparison would fail this {"1.2", "1.2.0", 0}, // Different length, equal {"1.2.1", "1.2", 1}, // Different length, v1 > v2 } for _, tc := range tests { result := doctor.CompareVersions(tc.v1, tc.v2) if result != tc.expected { t.Errorf("doctor.CompareVersions(%q, %q) = %d, expected %d", tc.v1, tc.v2, result, tc.expected) } } } func TestCheckMultipleDatabases(t *testing.T) { tests := []struct { name string dbFiles []string expectedStatus string expectWarning bool }{ { name: "no databases", dbFiles: []string{}, expectedStatus: doctor.StatusOK, expectWarning: false, }, { name: "single database", dbFiles: []string{"beads.db"}, expectedStatus: doctor.StatusOK, expectWarning: false, }, { name: "multiple databases", dbFiles: []string{"beads.db", "old.db"}, expectedStatus: doctor.StatusWarning, expectWarning: true, }, { name: "backup files ignored", dbFiles: []string{"beads.db", "beads.backup.db"}, expectedStatus: doctor.StatusOK, expectWarning: false, }, { name: "vc.db ignored", dbFiles: []string{"beads.db", "vc.db"}, expectedStatus: doctor.StatusOK, expectWarning: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } // Create test database files for _, dbFile := range tc.dbFiles { path := filepath.Join(beadsDir, dbFile) if err := os.WriteFile(path, []byte{}, 0644); err != nil { t.Fatal(err) } } check := doctor.CheckMultipleDatabases(tmpDir) if check.Status != tc.expectedStatus { t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status) } if tc.expectWarning && check.Fix == "" { t.Error("Expected fix message for warning status") } }) } } func TestCheckPermissions(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } check := doctor.CheckPermissions(tmpDir) if check.Status != doctor.StatusOK { t.Errorf("Expected ok status for writable directory, got %s: %s", check.Status, check.Message) } } func TestCheckDatabaseJSONLSync(t *testing.T) { tests := []struct { name string hasDB bool hasJSONL bool expectedStatus string }{ { name: "no database", hasDB: false, hasJSONL: true, expectedStatus: doctor.StatusOK, }, { name: "no JSONL", hasDB: true, hasJSONL: false, expectedStatus: doctor.StatusOK, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } if tc.hasDB { dbPath := filepath.Join(beadsDir, "beads.db") // Skip database creation tests due to SQLite driver registration in tests // The real doctor command works fine with actual databases if tc.hasJSONL { t.Skip("Database creation in tests requires complex driver setup") } // For no-JSONL case, just create an empty file if err := os.WriteFile(dbPath, []byte{}, 0644); err != nil { t.Fatal(err) } } if tc.hasJSONL { jsonlPath := filepath.Join(beadsDir, "issues.jsonl") if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil { t.Fatal(err) } } check := doctor.CheckDatabaseJSONLSync(tmpDir) if check.Status != tc.expectedStatus { t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status) } }) } } func TestCountJSONLIssuesWithMalformedLines(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } // Create JSONL file with mixed valid and invalid JSON jsonlPath := filepath.Join(beadsDir, "issues.jsonl") jsonlContent := `{"id":"test-001","title":"Valid 1"} invalid json line here {"id":"test-002","title":"Valid 2"} {"broken": incomplete {"id":"test-003","title":"Valid 3"} ` if err := os.WriteFile(jsonlPath, []byte(jsonlContent), 0644); err != nil { t.Fatal(err) } count, prefixes, err := doctor.CountJSONLIssues(jsonlPath) // Should count valid issues (3) if count != 3 { t.Errorf("Expected 3 issues, got %d", count) } // Should have 1 error for malformed lines if err == nil { t.Error("Expected error for malformed lines, got nil") } if !strings.Contains(err.Error(), "skipped") { t.Errorf("Expected error about skipped lines, got: %v", err) } // Should have extracted prefix if prefixes["test"] != 3 { t.Errorf("Expected 3 'test' prefixes, got %d", prefixes["test"]) } } func TestCheckGitHooks(t *testing.T) { tests := []struct { name string hasGitDir bool installedHooks []string expectedStatus string expectWarning bool }{ { name: "not a git repository", hasGitDir: false, installedHooks: []string{}, expectedStatus: doctor.StatusOK, expectWarning: false, }, { name: "all hooks installed", hasGitDir: true, installedHooks: []string{"pre-commit", "post-merge", "pre-push"}, expectedStatus: doctor.StatusOK, expectWarning: false, }, { name: "no hooks installed", hasGitDir: true, installedHooks: []string{}, expectedStatus: doctor.StatusWarning, expectWarning: true, }, { name: "some hooks installed", hasGitDir: true, installedHooks: []string{"pre-commit"}, expectedStatus: doctor.StatusWarning, expectWarning: true, }, { name: "partial hooks installed", hasGitDir: true, installedHooks: []string{"pre-commit", "post-merge"}, expectedStatus: doctor.StatusWarning, expectWarning: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() // Always change to tmpDir to ensure GetGitDir detects the correct context oldDir, err := os.Getwd() if err != nil { t.Fatalf("Failed to get current directory: %v", err) } if err := os.Chdir(tmpDir); err != nil { t.Fatalf("Failed to change to test directory: %v", err) } defer func() { _ = os.Chdir(oldDir) }() if tc.hasGitDir { // Initialize a real git repository in the test directory cmd := exec.Command("git", "init") cmd.Dir = tmpDir if err := cmd.Run(); err != nil { t.Skipf("Skipping test: git init failed: %v", err) } gitDir, err := git.GetGitDir() if err != nil { t.Fatalf("git.GetGitDir() failed: %v", err) } hooksDir := filepath.Join(gitDir, "hooks") if err := os.MkdirAll(hooksDir, 0750); err != nil { t.Fatal(err) } // Create installed hooks for _, hookName := range tc.installedHooks { hookPath := filepath.Join(hooksDir, hookName) if err := os.WriteFile(hookPath, []byte("#!/bin/sh\n"), 0755); err != nil { t.Fatal(err) } } } check := doctor.CheckGitHooks() if check.Status != tc.expectedStatus { t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status) } if tc.expectWarning && check.Fix == "" { t.Error("Expected fix message for warning status") } if !tc.expectWarning && check.Fix != "" && tc.hasGitDir { t.Error("Expected no fix message for non-warning status") } }) } } func TestCheckClaudePlugin(t *testing.T) { tests := []struct { name string claudeCodeEnv string expectedStatus string expectedMsg string }{ { name: "not running in claude code", claudeCodeEnv: "", expectedStatus: doctor.StatusOK, expectedMsg: "N/A (not running in Claude Code)", }, { name: "not running in claude code (0)", claudeCodeEnv: "0", expectedStatus: doctor.StatusOK, expectedMsg: "N/A (not running in Claude Code)", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Save original env origEnv := os.Getenv("CLAUDECODE") defer func() { if origEnv == "" { os.Unsetenv("CLAUDECODE") } else { os.Setenv("CLAUDECODE", origEnv) } }() // Set test env if tc.claudeCodeEnv == "" { os.Unsetenv("CLAUDECODE") } else { os.Setenv("CLAUDECODE", tc.claudeCodeEnv) } check := doctor.CheckClaudePlugin() if check.Status != tc.expectedStatus { t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status) } if check.Message != tc.expectedMsg { t.Errorf("Expected message %q, got %q", tc.expectedMsg, check.Message) } }) } } func TestGetClaudePluginVersion(t *testing.T) { tests := []struct { name string pluginJSON string expectInstalled bool expectVersion string expectError bool }{ { name: "plugin installed v1 format", pluginJSON: `{ "version": 1, "plugins": { "beads@beads-marketplace": { "version": "0.21.3" } } }`, expectInstalled: true, expectVersion: "0.21.3", expectError: false, }, { name: "plugin installed v2 format (GH#741)", pluginJSON: `{ "version": 2, "plugins": { "beads@beads-marketplace": [ { "scope": "user", "installPath": "/path/to/plugin", "version": "1.0.0", "installedAt": "2025-11-25T19:20:27.889Z", "lastUpdated": "2025-11-25T19:20:27.889Z", "gitCommitSha": "abc123", "isLocal": true } ] } }`, expectInstalled: true, expectVersion: "1.0.0", expectError: false, }, { name: "plugin not installed v2 format", pluginJSON: `{ "version": 2, "plugins": { "other-plugin@marketplace": [ { "scope": "user", "version": "2.0.0" } ] } }`, expectInstalled: false, expectVersion: "", expectError: false, }, { name: "plugin not installed v1 format", pluginJSON: `{ "version": 1, "plugins": { "other-plugin@marketplace": { "version": "1.0.0" } } }`, expectInstalled: false, expectVersion: "", expectError: false, }, { name: "invalid json", pluginJSON: `{invalid json`, expectInstalled: false, expectVersion: "", expectError: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Create temp dir with plugin file tmpHome := t.TempDir() pluginDir := filepath.Join(tmpHome, ".claude", "plugins") if err := os.MkdirAll(pluginDir, 0750); err != nil { t.Fatal(err) } pluginPath := filepath.Join(pluginDir, "installed_plugins.json") if err := os.WriteFile(pluginPath, []byte(tc.pluginJSON), 0600); err != nil { t.Fatal(err) } // Temporarily override home directory origHome := os.Getenv("HOME") os.Setenv("HOME", tmpHome) defer os.Setenv("HOME", origHome) version, installed, err := doctor.GetClaudePluginVersion() if tc.expectError && err == nil { t.Error("Expected error, got nil") } if !tc.expectError && err != nil { t.Errorf("Unexpected error: %v", err) } if installed != tc.expectInstalled { t.Errorf("Expected installed=%v, got %v", tc.expectInstalled, installed) } if version != tc.expectVersion { t.Errorf("Expected version %q, got %q", tc.expectVersion, version) } }) } } func TestCheckMetadataVersionTracking(t *testing.T) { // GH#662: Tests updated to use .local_version file instead of metadata.json:LastBdVersion tests := []struct { name string setupVersion func(beadsDir string) error expectedStatus string expectWarning bool }{ { name: "valid current version", setupVersion: func(beadsDir string) error { return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte(Version+"\n"), 0644) }, expectedStatus: doctor.StatusOK, expectWarning: false, }, { name: "slightly outdated version", setupVersion: func(beadsDir string) error { // Use a version that's less than 10 minor versions behind current return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("0.30.0\n"), 0644) }, expectedStatus: doctor.StatusOK, expectWarning: false, }, { name: "very old version", setupVersion: func(beadsDir string) error { // Use a version that's 10+ minor versions behind current (triggers warning) return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("0.24.0\n"), 0644) }, expectedStatus: doctor.StatusWarning, expectWarning: true, }, { name: "empty version file", setupVersion: func(beadsDir string) error { return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte(""), 0644) }, expectedStatus: doctor.StatusWarning, expectWarning: true, }, { name: "invalid version format", setupVersion: func(beadsDir string) error { return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("invalid-version\n"), 0644) }, expectedStatus: doctor.StatusWarning, expectWarning: true, }, { name: "missing .local_version file", setupVersion: func(beadsDir string) error { // Don't create .local_version return nil }, expectedStatus: doctor.StatusWarning, expectWarning: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } // Setup .local_version file if err := tc.setupVersion(beadsDir); err != nil { t.Fatal(err) } check := doctor.CheckMetadataVersionTracking(tmpDir, Version) if check.Status != tc.expectedStatus { t.Errorf("Expected status %s, got %s (message: %s)", tc.expectedStatus, check.Status, check.Message) } if tc.expectWarning && check.Status == doctor.StatusWarning && check.Fix == "" { t.Error("Expected fix message for warning status") } }) } } func TestIsValidSemver(t *testing.T) { tests := []struct { version string expected bool }{ {"0.24.2", true}, {"1.0.0", true}, {"0.1", true}, // Major.minor is valid {"1", true}, // Just major is valid {"", false}, // Empty is invalid {"invalid", false}, // Non-numeric is invalid {"0.a.2", false}, // Letters in parts are invalid {"1.2.3.4", true}, // Extra parts are ok } for _, tc := range tests { result := doctor.IsValidSemver(tc.version) if result != tc.expected { t.Errorf("doctor.IsValidSemver(%q) = %v, expected %v", tc.version, result, tc.expected) } } } func TestParseVersionParts(t *testing.T) { tests := []struct { version string expected []int }{ {"0.24.2", []int{0, 24, 2}}, {"1.0.0", []int{1, 0, 0}}, {"0.1", []int{0, 1}}, {"1", []int{1}}, {"", []int{}}, {"invalid", []int{}}, {"1.a.3", []int{1}}, // Stops at first non-numeric part } for _, tc := range tests { result := doctor.ParseVersionParts(tc.version) if len(result) != len(tc.expected) { t.Errorf("doctor.ParseVersionParts(%q) returned %d parts, expected %d", tc.version, len(result), len(tc.expected)) continue } for i := range result { if result[i] != tc.expected[i] { t.Errorf("doctor.ParseVersionParts(%q)[%d] = %d, expected %d", tc.version, i, result[i], tc.expected[i]) } } } } func TestCheckSyncBranchConfig(t *testing.T) { tests := []struct { name string setupFunc func(t *testing.T, tmpDir string) expectedStatus string expectWarning bool }{ { name: "no beads directory", setupFunc: func(t *testing.T, tmpDir string) { // No .beads directory }, expectedStatus: doctor.StatusOK, expectWarning: false, }, { name: "not a git repo", setupFunc: func(t *testing.T, tmpDir string) { beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } }, expectedStatus: doctor.StatusOK, expectWarning: false, }, { name: "sync.branch configured via env var", setupFunc: func(t *testing.T, tmpDir string) { // Initialize git repo cmd := exec.Command("git", "init") cmd.Dir = tmpDir if err := cmd.Run(); err != nil { t.Fatal(err) } // Create .beads directory beadsDir := filepath.Join(tmpDir, ".beads") if err := os.Mkdir(beadsDir, 0750); err != nil { t.Fatal(err) } // Set env var (simulates config.yaml or BEADS_SYNC_BRANCH) t.Setenv("BEADS_SYNC_BRANCH", "beads-sync") }, expectedStatus: doctor.StatusOK, expectWarning: false, }, // Note: Tests for "not configured" scenarios are difficult because viper // reads config.yaml at startup from the test's working directory. // The env var tests above verify the core functionality. // For full integration testing, use actual fresh clones. } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() tc.setupFunc(t, tmpDir) result := doctor.CheckSyncBranchConfig(tmpDir) if result.Status != tc.expectedStatus { t.Errorf("Expected status %q, got %q", tc.expectedStatus, result.Status) } if tc.expectWarning && result.Fix == "" { t.Error("Expected Fix field to be set for warning status") } }) } } // TestInteractiveFlagParsing verifies the --interactive flag is registered (bd-3xl) func TestInteractiveFlagParsing(t *testing.T) { // Verify the flag exists and has the right short form flag := doctorCmd.Flags().Lookup("interactive") if flag == nil { t.Fatal("--interactive flag not found") } if flag.Shorthand != "i" { t.Errorf("Expected shorthand 'i', got %q", flag.Shorthand) } if flag.DefValue != "false" { t.Errorf("Expected default value 'false', got %q", flag.DefValue) } } // TestOutputFlagParsing verifies the --output flag is registered (bd-9cc) func TestOutputFlagParsing(t *testing.T) { flag := doctorCmd.Flags().Lookup("output") if flag == nil { t.Fatal("--output flag not found") } if flag.Shorthand != "o" { t.Errorf("Expected shorthand 'o', got %q", flag.Shorthand) } if flag.DefValue != "" { t.Errorf("Expected default value '', got %q", flag.DefValue) } } // TestExportDiagnostics verifies the export functionality (bd-9cc) func TestExportDiagnostics(t *testing.T) { tmpDir := t.TempDir() outputPath := filepath.Join(tmpDir, "diagnostics.json") // Create a test result result := doctorResult{ Path: "/test/path", CLIVersion: "0.29.0", OverallOK: true, Timestamp: "2025-01-01T00:00:00Z", Platform: map[string]string{ "os_arch": "darwin/arm64", "go_version": "go1.21.0", "sqlite_version": "3.42.0", }, Checks: []doctorCheck{ { Name: "Installation", Status: "ok", Message: ".beads/ directory found", }, { Name: "Git Hooks", Status: "warning", Message: "No hooks installed", Fix: "Run 'bd hooks install'", }, }, } // Export to file if err := exportDiagnostics(result, outputPath); err != nil { t.Fatalf("exportDiagnostics failed: %v", err) } // Read the file back data, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("Failed to read exported file: %v", err) } // Parse the JSON var decoded doctorResult if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("Failed to parse exported JSON: %v", err) } // Verify fields if decoded.Path != result.Path { t.Errorf("Path mismatch: got %q, want %q", decoded.Path, result.Path) } if decoded.CLIVersion != result.CLIVersion { t.Errorf("CLIVersion mismatch: got %q, want %q", decoded.CLIVersion, result.CLIVersion) } if decoded.OverallOK != result.OverallOK { t.Errorf("OverallOK mismatch: got %v, want %v", decoded.OverallOK, result.OverallOK) } if decoded.Timestamp != result.Timestamp { t.Errorf("Timestamp mismatch: got %q, want %q", decoded.Timestamp, result.Timestamp) } if decoded.Platform["os_arch"] != result.Platform["os_arch"] { t.Errorf("Platform.os_arch mismatch: got %q, want %q", decoded.Platform["os_arch"], result.Platform["os_arch"]) } if len(decoded.Checks) != len(result.Checks) { t.Errorf("Checks length mismatch: got %d, want %d", len(decoded.Checks), len(result.Checks)) } } // TestExportDiagnosticsInvalidPath verifies error handling (bd-9cc) func TestExportDiagnosticsInvalidPath(t *testing.T) { result := doctorResult{ Path: "/test/path", OverallOK: true, } // Try to export to an invalid path err := exportDiagnostics(result, "/nonexistent/directory/diagnostics.json") if err == nil { t.Error("Expected error for invalid path, got nil") } } // TestCheckSyncBranchHookCompatibility tests the sync-branch hook compatibility check (issue #532) // Note: We use BEADS_SYNC_BRANCH env var to control sync-branch detection because the config // package reads from the actual beads repo's config.yaml. Only test cases with syncBranchEnv // set to a non-empty value are reliable. func TestCheckSyncBranchHookCompatibility(t *testing.T) { tests := []struct { name string syncBranchEnv string // BEADS_SYNC_BRANCH env var (must be non-empty to override config.yaml) hasGitDir bool hookVersion string // Empty means no hook, "custom" means non-bd hook expectedStatus string }{ { name: "sync-branch configured, no git repo", syncBranchEnv: "beads-sync", hasGitDir: false, hookVersion: "", expectedStatus: doctor.StatusOK, // N/A case }, { name: "sync-branch configured, no pre-push hook", syncBranchEnv: "beads-sync", hasGitDir: true, hookVersion: "", expectedStatus: doctor.StatusOK, // Covered by other check }, { name: "sync-branch configured, custom hook", syncBranchEnv: "beads-sync", hasGitDir: true, hookVersion: "custom", expectedStatus: doctor.StatusWarning, }, { name: "sync-branch configured, old hook (0.24.2)", syncBranchEnv: "beads-sync", hasGitDir: true, hookVersion: "0.24.2", expectedStatus: doctor.StatusError, }, { name: "sync-branch configured, old hook (0.28.0)", syncBranchEnv: "beads-sync", hasGitDir: true, hookVersion: "0.28.0", expectedStatus: doctor.StatusError, }, { name: "sync-branch configured, compatible hook (0.29.0)", syncBranchEnv: "beads-sync", hasGitDir: true, hookVersion: "0.29.0", expectedStatus: doctor.StatusOK, }, { name: "sync-branch configured, newer hook (0.30.0)", syncBranchEnv: "beads-sync", hasGitDir: true, hookVersion: "0.30.0", expectedStatus: doctor.StatusOK, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() // Always set environment variable to control sync-branch detection // This overrides any config.yaml value in the actual beads repo t.Setenv("BEADS_SYNC_BRANCH", tc.syncBranchEnv) if tc.hasGitDir { // Initialize a real git repo (git rev-parse needs this) cmd := exec.Command("git", "init") cmd.Dir = tmpDir if err := cmd.Run(); err != nil { t.Fatal(err) } // Create pre-push hook if specified if tc.hookVersion != "" { hooksDir := filepath.Join(tmpDir, ".git", "hooks") hookPath := filepath.Join(hooksDir, "pre-push") var hookContent string if tc.hookVersion == "custom" { hookContent = "#!/bin/sh\n# Custom hook\nexit 0\n" } else { hookContent = fmt.Sprintf("#!/bin/sh\n# bd-hooks-version: %s\nexit 0\n", tc.hookVersion) } if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil { t.Fatal(err) } } } check := doctor.CheckSyncBranchHookCompatibility(tmpDir) if check.Status != tc.expectedStatus { t.Errorf("Expected status %s, got %s (message: %s)", tc.expectedStatus, check.Status, check.Message) } // Error case should have a fix message if tc.expectedStatus == doctor.StatusError && check.Fix == "" { t.Error("Expected fix message for error status") } }) } } // TestCheckSyncBranchHookQuick tests the quick sync-branch hook check (issue #532) // Note: We use BEADS_SYNC_BRANCH env var to control sync-branch detection. func TestCheckSyncBranchHookQuick(t *testing.T) { tests := []struct { name string syncBranchEnv string hasGitDir bool hookVersion string expectIssue bool }{ { name: "old hook with sync-branch", syncBranchEnv: "beads-sync", hasGitDir: true, hookVersion: "0.24.0", expectIssue: true, }, { name: "compatible hook with sync-branch", syncBranchEnv: "beads-sync", hasGitDir: true, hookVersion: "0.29.0", expectIssue: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() // Always set environment variable to control sync-branch detection // This overrides any config.yaml value in the actual beads repo t.Setenv("BEADS_SYNC_BRANCH", tc.syncBranchEnv) if tc.hasGitDir { // Initialize a real git repo (git rev-parse needs this) cmd := exec.Command("git", "init") cmd.Dir = tmpDir if err := cmd.Run(); err != nil { t.Fatal(err) } if tc.hookVersion != "" { hooksDir := filepath.Join(tmpDir, ".git", "hooks") hookPath := filepath.Join(hooksDir, "pre-push") hookContent := fmt.Sprintf("#!/bin/sh\n# bd-hooks-version: %s\nexit 0\n", tc.hookVersion) if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil { t.Fatal(err) } } } issue := doctor.CheckSyncBranchHookQuick(tmpDir) if tc.expectIssue && issue == "" { t.Error("Expected issue to be reported, got empty string") } if !tc.expectIssue && issue != "" { t.Errorf("Expected no issue, got: %s", issue) } }) } }