package setup import ( "bytes" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" ) func newClaudeTestEnv(t *testing.T) (claudeEnv, *bytes.Buffer, *bytes.Buffer) { t.Helper() root := t.TempDir() projectDir := filepath.Join(root, "project") homeDir := filepath.Join(root, "home") if err := os.MkdirAll(projectDir, 0o755); err != nil { t.Fatalf("mkdir project: %v", err) } if err := os.MkdirAll(homeDir, 0o755); err != nil { t.Fatalf("mkdir home: %v", err) } stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} env := claudeEnv{ stdout: stdout, stderr: stderr, homeDir: homeDir, projectDir: projectDir, ensureDir: EnsureDir, readFile: os.ReadFile, writeFile: func(path string, data []byte) error { return atomicWriteFile(path, data) }, } return env, stdout, stderr } func stubClaudeEnvProvider(t *testing.T, env claudeEnv, err error) { t.Helper() orig := claudeEnvProvider claudeEnvProvider = func() (claudeEnv, error) { if err != nil { return claudeEnv{}, err } return env, nil } t.Cleanup(func() { claudeEnvProvider = orig }) } func writeSettings(t *testing.T, path string, settings map[string]interface{}) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir settings dir: %v", err) } data, err := json.MarshalIndent(settings, "", " ") if err != nil { t.Fatalf("marshal settings: %v", err) } if err := atomicWriteFile(path, data); err != nil { t.Fatalf("write settings: %v", err) } } func TestAddHookCommand(t *testing.T) { tests := []struct { name string existingHooks map[string]interface{} event string command string wantAdded bool }{ { name: "add hook to empty hooks", existingHooks: make(map[string]interface{}), event: "SessionStart", command: "bd prime", wantAdded: true, }, { name: "add stealth hook to empty hooks", existingHooks: make(map[string]interface{}), event: "SessionStart", command: "bd prime --stealth", wantAdded: true, }, { name: "hook already exists", existingHooks: map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "bd prime", }, }, }, }, }, event: "SessionStart", command: "bd prime", wantAdded: false, }, { name: "stealth hook already exists", existingHooks: map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "bd prime --stealth", }, }, }, }, }, event: "SessionStart", command: "bd prime --stealth", wantAdded: false, }, { name: "add second hook alongside existing", existingHooks: map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "other command", }, }, }, }, }, event: "SessionStart", command: "bd prime", wantAdded: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := addHookCommand(tt.existingHooks, tt.event, tt.command) if got != tt.wantAdded { t.Errorf("addHookCommand() = %v, want %v", got, tt.wantAdded) } // Verify hook exists in structure eventHooks, ok := tt.existingHooks[tt.event].([]interface{}) if !ok { t.Fatal("Event hooks not found") } found := false for _, hook := range eventHooks { hookMap := hook.(map[string]interface{}) commands := hookMap["hooks"].([]interface{}) for _, cmd := range commands { cmdMap := cmd.(map[string]interface{}) if cmdMap["command"] == tt.command { found = true break } } } if !found { t.Errorf("Hook command %q not found in event %q", tt.command, tt.event) } }) } } func TestRemoveHookCommand(t *testing.T) { tests := []struct { name string existingHooks map[string]interface{} event string command string wantRemaining int }{ { name: "remove only hook", existingHooks: map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "bd prime", }, }, }, }, }, event: "SessionStart", command: "bd prime", wantRemaining: 0, }, { name: "remove stealth hook", existingHooks: map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "bd prime --stealth", }, }, }, }, }, event: "SessionStart", command: "bd prime --stealth", wantRemaining: 0, }, { name: "remove one of multiple hooks", existingHooks: map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "other command", }, }, }, map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "bd prime", }, }, }, }, }, event: "SessionStart", command: "bd prime", wantRemaining: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { removeHookCommand(tt.existingHooks, tt.event, tt.command) eventHooks, ok := tt.existingHooks[tt.event].([]interface{}) if !ok && tt.wantRemaining > 0 { t.Fatal("Event hooks not found") } if len(eventHooks) != tt.wantRemaining { t.Errorf("Expected %d remaining hooks, got %d", tt.wantRemaining, len(eventHooks)) } // Verify target hook is actually gone for _, hook := range eventHooks { hookMap := hook.(map[string]interface{}) commands := hookMap["hooks"].([]interface{}) for _, cmd := range commands { cmdMap := cmd.(map[string]interface{}) if cmdMap["command"] == tt.command { t.Errorf("Hook command %q still present after removal", tt.command) } } } }) } } // TestRemoveHookCommandNoNull verifies that removing all hooks deletes the key // instead of setting it to null. GH#955: null values in hooks cause Claude Code to fail. func TestRemoveHookCommandNoNull(t *testing.T) { hooks := map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "bd prime", }, }, }, }, } removeHookCommand(hooks, "SessionStart", "bd prime") // Key should be deleted, not set to null or empty array if _, exists := hooks["SessionStart"]; exists { t.Error("Expected SessionStart key to be deleted after removing all hooks") } // Verify JSON serialization doesn't produce null data, err := json.Marshal(hooks) if err != nil { t.Fatalf("marshal: %v", err) } if strings.Contains(string(data), "null") { t.Errorf("JSON contains null: %s", data) } } // TestInstallClaudeCleanupNullHooks verifies that install cleans up existing null values. // GH#955: null values left by previous buggy removal cause Claude Code to fail. func TestInstallClaudeCleanupNullHooks(t *testing.T) { env, stdout, _ := newClaudeTestEnv(t) // Create settings file with null hooks (simulating the bug) settingsPath := globalSettingsPath(env.homeDir) writeSettings(t, settingsPath, map[string]interface{}{ "hooks": map[string]interface{}{ "SessionStart": nil, "PreCompact": nil, }, }) // Install should clean up null values and add proper hooks err := installClaude(env, false, false) if err != nil { t.Fatalf("install failed: %v", err) } // Verify hooks were properly added if !strings.Contains(stdout.String(), "Registered SessionStart hook") { t.Error("Expected SessionStart hook to be registered") } // Read back the file and verify no null values data, err := env.readFile(settingsPath) if err != nil { t.Fatalf("read settings: %v", err) } if strings.Contains(string(data), "null") { t.Errorf("Settings file still contains null: %s", data) } // Verify it parses as valid Claude settings var settings map[string]interface{} if err := json.Unmarshal(data, &settings); err != nil { t.Fatalf("parse settings: %v", err) } hooks, ok := settings["hooks"].(map[string]interface{}) if !ok { t.Fatal("hooks section missing") } for _, event := range []string{"SessionStart", "PreCompact"} { eventHooks, ok := hooks[event].([]interface{}) if !ok { t.Errorf("%s should be an array, not nil or missing", event) } if len(eventHooks) == 0 { t.Errorf("%s should have hooks", event) } } } func TestHasBeadsHooks(t *testing.T) { tmpDir := t.TempDir() tests := []struct { name string settingsData map[string]interface{} want bool }{ { name: "has bd prime hook", settingsData: map[string]interface{}{ "hooks": map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "bd prime", }, }, }, }, }, }, want: true, }, { name: "has bd prime --stealth hook", settingsData: map[string]interface{}{ "hooks": map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "bd prime --stealth", }, }, }, }, }, }, want: true, }, { name: "has bd prime in PreCompact", settingsData: map[string]interface{}{ "hooks": map[string]interface{}{ "PreCompact": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "bd prime", }, }, }, }, }, }, want: true, }, { name: "has bd prime --stealth in PreCompact", settingsData: map[string]interface{}{ "hooks": map[string]interface{}{ "PreCompact": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "bd prime --stealth", }, }, }, }, }, }, want: true, }, { name: "no hooks", settingsData: map[string]interface{}{}, want: false, }, { name: "has other hooks but not bd prime", settingsData: map[string]interface{}{ "hooks": map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{ "type": "command", "command": "other command", }, }, }, }, }, }, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { settingsPath := filepath.Join(tmpDir, "settings.json") data, err := json.Marshal(tt.settingsData) if err != nil { t.Fatalf("Failed to marshal test data: %v", err) } if err := os.WriteFile(settingsPath, data, 0o644); err != nil { t.Fatalf("Failed to write test file: %v", err) } got := hasBeadsHooks(settingsPath) if got != tt.want { t.Errorf("hasBeadsHooks() = %v, want %v", got, tt.want) } }) } } func TestIdempotency(t *testing.T) { // Test that running addHookCommand twice doesn't duplicate hooks hooks := make(map[string]interface{}) // First add added1 := addHookCommand(hooks, "SessionStart", "bd prime") if !added1 { t.Error("First call should have added the hook") } // Second add (should detect existing) added2 := addHookCommand(hooks, "SessionStart", "bd prime") if added2 { t.Error("Second call should have detected existing hook") } // Verify only one hook exists eventHooks := hooks["SessionStart"].([]interface{}) if len(eventHooks) != 1 { t.Errorf("Expected 1 hook, got %d", len(eventHooks)) } } // Test that running addHookCommand twice with stealth doesn't duplicate hooks func TestIdempotencyWithStealth(t *testing.T) { hooks := make(map[string]any) if !addHookCommand(hooks, "SessionStart", "bd prime --stealth") { t.Error("First call should have added the stealth hook") } // Second add (should detect existing) if addHookCommand(hooks, "SessionStart", "bd prime --stealth") { t.Error("Second call should have detected existing stealth hook") } // Verify only one hook exists eventHooks := hooks["SessionStart"].([]any) if len(eventHooks) != 1 { t.Errorf("Expected 1 hook, got %d", len(eventHooks)) } // and that it's the correct one hookMap := eventHooks[0].(map[string]any) commands := hookMap["hooks"].([]any) cmdMap := commands[0].(map[string]any) if cmdMap["command"] != "bd prime --stealth" { t.Errorf("Expected 'bd prime --stealth', got %v", cmdMap["command"]) } } func TestInstallClaudeProject(t *testing.T) { env, stdout, stderr := newClaudeTestEnv(t) if err := installClaude(env, true, false); err != nil { t.Fatalf("installClaude: %v", err) } data, err := os.ReadFile(projectSettingsPath(env.projectDir)) if err != nil { t.Fatalf("read project settings: %v", err) } var settings map[string]interface{} if err := json.Unmarshal(data, &settings); err != nil { t.Fatalf("unmarshal settings: %v", err) } if !hasBeadsHooks(projectSettingsPath(env.projectDir)) { t.Fatal("project hooks not detected") } if !strings.Contains(stdout.String(), "project") { t.Error("expected project installation message") } if stderr.Len() != 0 { t.Errorf("unexpected stderr output: %s", stderr.String()) } } func TestInstallClaudeGlobalStealth(t *testing.T) { env, stdout, _ := newClaudeTestEnv(t) if err := installClaude(env, false, true); err != nil { t.Fatalf("installClaude: %v", err) } data, err := os.ReadFile(globalSettingsPath(env.homeDir)) if err != nil { t.Fatalf("read global settings: %v", err) } if !strings.Contains(string(data), "bd prime --stealth") { t.Error("expected stealth command in settings") } if !strings.Contains(stdout.String(), "globally") { t.Error("expected global installation message") } } func TestInstallClaudeErrors(t *testing.T) { t.Run("invalid json", func(t *testing.T) { env, _, stderr := newClaudeTestEnv(t) path := projectSettingsPath(env.projectDir) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil { t.Fatalf("write file: %v", err) } if err := installClaude(env, true, false); err == nil { t.Fatal("expected parse error") } if !strings.Contains(stderr.String(), "failed to parse") { t.Error("expected parse error output") } }) t.Run("ensure dir error", func(t *testing.T) { env, _, _ := newClaudeTestEnv(t) env.ensureDir = func(string, os.FileMode) error { return errors.New("boom") } if err := installClaude(env, true, false); err == nil { t.Fatal("expected ensureDir error") } }) } func TestCheckClaudeScenarios(t *testing.T) { t.Run("global hooks", func(t *testing.T) { env, stdout, _ := newClaudeTestEnv(t) writeSettings(t, globalSettingsPath(env.homeDir), map[string]interface{}{ "hooks": map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{"type": "command", "command": "bd prime"}, }, }, }, }, }) if err := checkClaude(env); err != nil { t.Fatalf("checkClaude: %v", err) } if !strings.Contains(stdout.String(), "Global hooks installed") { t.Error("expected global hooks message") } }) t.Run("project hooks", func(t *testing.T) { env, stdout, _ := newClaudeTestEnv(t) writeSettings(t, projectSettingsPath(env.projectDir), map[string]interface{}{ "hooks": map[string]interface{}{ "PreCompact": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{"type": "command", "command": "bd prime"}, }, }, }, }, }) if err := checkClaude(env); err != nil { t.Fatalf("checkClaude: %v", err) } if !strings.Contains(stdout.String(), "Project hooks installed") { t.Error("expected project hooks message") } }) t.Run("missing hooks", func(t *testing.T) { env, stdout, _ := newClaudeTestEnv(t) if err := checkClaude(env); !errors.Is(err, errClaudeHooksMissing) { t.Fatalf("expected errClaudeHooksMissing, got %v", err) } if !strings.Contains(stdout.String(), "Run: bd setup claude") { t.Error("expected guidance message") } }) } func TestRemoveClaudeScenarios(t *testing.T) { t.Run("remove global hooks", func(t *testing.T) { env, stdout, _ := newClaudeTestEnv(t) path := globalSettingsPath(env.homeDir) writeSettings(t, path, map[string]interface{}{ "hooks": map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{"type": "command", "command": "bd prime"}, map[string]interface{}{"type": "command", "command": "other"}, }, }, }, }, }) if err := removeClaude(env, false); err != nil { t.Fatalf("removeClaude: %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("read file: %v", err) } if strings.Contains(string(data), "bd prime") { t.Error("expected bd prime hooks removed") } if !strings.Contains(stdout.String(), "hooks removed") { t.Error("expected success message") } }) t.Run("missing file", func(t *testing.T) { env, stdout, _ := newClaudeTestEnv(t) if err := removeClaude(env, true); err != nil { t.Fatalf("removeClaude: %v", err) } if !strings.Contains(stdout.String(), "No settings file found") { t.Error("expected missing file message") } }) t.Run("invalid json", func(t *testing.T) { env, _, stderr := newClaudeTestEnv(t) path := projectSettingsPath(env.projectDir) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil { t.Fatalf("write file: %v", err) } if err := removeClaude(env, true); err == nil { t.Fatal("expected parse error") } if !strings.Contains(stderr.String(), "failed to parse") { t.Error("expected parse error output") } }) } func TestClaudeWrappersExit(t *testing.T) { t.Run("install provider error", func(t *testing.T) { cap := stubSetupExit(t) stubClaudeEnvProvider(t, claudeEnv{}, errors.New("boom")) InstallClaude(false, false) if !cap.called || cap.code != 1 { t.Fatal("InstallClaude should exit on provider error") } }) t.Run("install internal error", func(t *testing.T) { cap := stubSetupExit(t) env, _, _ := newClaudeTestEnv(t) env.ensureDir = func(string, os.FileMode) error { return errors.New("boom") } stubClaudeEnvProvider(t, env, nil) InstallClaude(true, false) if !cap.called || cap.code != 1 { t.Fatal("InstallClaude should exit when installClaude fails") } }) t.Run("check missing hooks", func(t *testing.T) { cap := stubSetupExit(t) env, _, _ := newClaudeTestEnv(t) stubClaudeEnvProvider(t, env, nil) CheckClaude() if !cap.called || cap.code != 1 { t.Fatal("CheckClaude should exit when hooks missing") } }) t.Run("remove parse error", func(t *testing.T) { cap := stubSetupExit(t) env, _, _ := newClaudeTestEnv(t) path := globalSettingsPath(env.homeDir) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(path, []byte("oops"), 0o644); err != nil { t.Fatalf("write file: %v", err) } stubClaudeEnvProvider(t, env, nil) RemoveClaude(false) if !cap.called || cap.code != 1 { t.Fatal("RemoveClaude should exit on parse error") } }) }