package setup import ( "bytes" "encoding/json" "os" "path/filepath" "strings" "testing" ) func newGeminiTestEnv(t *testing.T) (geminiEnv, *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 := geminiEnv{ 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 stubGeminiEnvProvider(t *testing.T, env geminiEnv, err error) { t.Helper() orig := geminiEnvProvider geminiEnvProvider = func() (geminiEnv, error) { if err != nil { return geminiEnv{}, err } return env, nil } t.Cleanup(func() { geminiEnvProvider = orig }) } func writeGeminiSettings(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 := os.WriteFile(path, data, 0o644); err != nil { t.Fatalf("write settings: %v", err) } } func readGeminiSettings(t *testing.T, path string) map[string]interface{} { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read settings: %v", err) } var settings map[string]interface{} if err := json.Unmarshal(data, &settings); err != nil { t.Fatalf("unmarshal settings: %v", err) } return settings } func TestInstallGemini_Global(t *testing.T) { env, stdout, _ := newGeminiTestEnv(t) err := installGemini(env, false, false) if err != nil { t.Fatalf("installGemini: %v", err) } // Verify settings file created settingsPath := geminiGlobalSettingsPath(env.homeDir) settings := readGeminiSettings(t, settingsPath) // Verify hooks structure hooks, ok := settings["hooks"].(map[string]interface{}) if !ok { t.Fatal("expected hooks map") } // Check SessionStart hook sessionStart, ok := hooks["SessionStart"].([]interface{}) if !ok || len(sessionStart) == 0 { t.Fatal("expected SessionStart hooks") } // Check PreCompress hook (Gemini uses PreCompress, not PreCompact) preCompress, ok := hooks["PreCompress"].([]interface{}) if !ok || len(preCompress) == 0 { t.Fatal("expected PreCompress hooks") } // Verify output out := stdout.String() if !strings.Contains(out, "Installing Gemini CLI hooks globally") { t.Errorf("expected global install message, got: %s", out) } if !strings.Contains(out, "Gemini CLI integration installed") { t.Errorf("expected success message, got: %s", out) } } func TestInstallGemini_Project(t *testing.T) { env, stdout, _ := newGeminiTestEnv(t) err := installGemini(env, true, false) if err != nil { t.Fatalf("installGemini: %v", err) } // Verify settings file created in project dir settingsPath := geminiProjectSettingsPath(env.projectDir) if _, err := os.Stat(settingsPath); os.IsNotExist(err) { t.Fatalf("expected project settings file at %s", settingsPath) } out := stdout.String() if !strings.Contains(out, "Installing Gemini CLI hooks for this project") { t.Errorf("expected project install message, got: %s", out) } } func TestInstallGemini_Stealth(t *testing.T) { env, _, _ := newGeminiTestEnv(t) err := installGemini(env, false, true) if err != nil { t.Fatalf("installGemini: %v", err) } settingsPath := geminiGlobalSettingsPath(env.homeDir) settings := readGeminiSettings(t, settingsPath) hooks := settings["hooks"].(map[string]interface{}) sessionStart := hooks["SessionStart"].([]interface{}) hook := sessionStart[0].(map[string]interface{}) cmds := hook["hooks"].([]interface{}) cmd := cmds[0].(map[string]interface{}) if cmd["command"] != "bd prime --stealth" { t.Errorf("expected stealth command, got: %v", cmd["command"]) } } func TestInstallGemini_Idempotent(t *testing.T) { env, _, _ := newGeminiTestEnv(t) // Install twice if err := installGemini(env, false, false); err != nil { t.Fatalf("first install: %v", err) } if err := installGemini(env, false, false); err != nil { t.Fatalf("second install: %v", err) } // Should only have one hook per event settingsPath := geminiGlobalSettingsPath(env.homeDir) settings := readGeminiSettings(t, settingsPath) hooks := settings["hooks"].(map[string]interface{}) sessionStart := hooks["SessionStart"].([]interface{}) if len(sessionStart) != 1 { t.Errorf("expected 1 SessionStart hook, got %d", len(sessionStart)) } } func TestInstallGemini_PreservesExistingSettings(t *testing.T) { env, _, _ := newGeminiTestEnv(t) // Create settings with existing content settingsPath := geminiGlobalSettingsPath(env.homeDir) existingSettings := map[string]interface{}{ "someOtherSetting": "value", "hooks": map[string]interface{}{ "SomeOtherHook": []interface{}{ map[string]interface{}{"custom": "hook"}, }, }, } writeGeminiSettings(t, settingsPath, existingSettings) // Install Gemini hooks if err := installGemini(env, false, false); err != nil { t.Fatalf("installGemini: %v", err) } // Verify existing settings preserved settings := readGeminiSettings(t, settingsPath) if settings["someOtherSetting"] != "value" { t.Error("existing setting was not preserved") } hooks := settings["hooks"].(map[string]interface{}) if hooks["SomeOtherHook"] == nil { t.Error("existing hook was not preserved") } } func TestCheckGemini_NotInstalled(t *testing.T) { env, stdout, _ := newGeminiTestEnv(t) err := checkGemini(env) if err != errGeminiHooksMissing { t.Errorf("expected errGeminiHooksMissing, got: %v", err) } out := stdout.String() if !strings.Contains(out, "No hooks installed") { t.Errorf("expected 'No hooks installed' message, got: %s", out) } } func TestCheckGemini_GlobalInstalled(t *testing.T) { env, stdout, _ := newGeminiTestEnv(t) // Install hooks first if err := installGemini(env, false, false); err != nil { t.Fatalf("installGemini: %v", err) } // Reset stdout stdout.Reset() // Check should pass err := checkGemini(env) if err != nil { t.Errorf("expected no error, got: %v", err) } out := stdout.String() if !strings.Contains(out, "Global hooks installed") { t.Errorf("expected 'Global hooks installed' message, got: %s", out) } } func TestCheckGemini_ProjectInstalled(t *testing.T) { env, stdout, _ := newGeminiTestEnv(t) // Install project hooks if err := installGemini(env, true, false); err != nil { t.Fatalf("installGemini: %v", err) } stdout.Reset() err := checkGemini(env) if err != nil { t.Errorf("expected no error, got: %v", err) } out := stdout.String() if !strings.Contains(out, "Project hooks installed") { t.Errorf("expected 'Project hooks installed' message, got: %s", out) } } func TestRemoveGemini_Global(t *testing.T) { env, stdout, _ := newGeminiTestEnv(t) // Install first if err := installGemini(env, false, false); err != nil { t.Fatalf("installGemini: %v", err) } stdout.Reset() // Remove if err := removeGemini(env, false); err != nil { t.Fatalf("removeGemini: %v", err) } // Verify hooks removed settingsPath := geminiGlobalSettingsPath(env.homeDir) settings := readGeminiSettings(t, settingsPath) hooks := settings["hooks"].(map[string]interface{}) sessionStart, ok := hooks["SessionStart"].([]interface{}) if ok && len(sessionStart) > 0 { t.Error("SessionStart hooks should be empty") } out := stdout.String() if !strings.Contains(out, "Gemini CLI hooks removed") { t.Errorf("expected removal message, got: %s", out) } } func TestRemoveGemini_NoSettingsFile(t *testing.T) { env, stdout, _ := newGeminiTestEnv(t) // Remove without installing first err := removeGemini(env, false) if err != nil { t.Errorf("expected no error for missing file, got: %v", err) } out := stdout.String() if !strings.Contains(out, "No settings file found") { t.Errorf("expected 'No settings file found' message, got: %s", out) } } func TestRemoveGemini_PreservesOtherHooks(t *testing.T) { env, _, _ := newGeminiTestEnv(t) // Create settings with other hooks settingsPath := geminiGlobalSettingsPath(env.homeDir) existingSettings := 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{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{"type": "command", "command": "other-command"}, }, }, }, }, } writeGeminiSettings(t, settingsPath, existingSettings) // Remove bd prime hooks if err := removeGemini(env, false); err != nil { t.Fatalf("removeGemini: %v", err) } // Verify other hooks preserved settings := readGeminiSettings(t, settingsPath) hooks := settings["hooks"].(map[string]interface{}) sessionStart := hooks["SessionStart"].([]interface{}) if len(sessionStart) != 1 { t.Errorf("expected 1 remaining hook, got %d", len(sessionStart)) } // Verify it's the other command, not bd prime hook := sessionStart[0].(map[string]interface{}) cmds := hook["hooks"].([]interface{}) cmd := cmds[0].(map[string]interface{}) if cmd["command"] == "bd prime" || cmd["command"] == "bd prime --stealth" { t.Error("bd prime hook should have been removed") } } func TestHasGeminiBeadsHooks(t *testing.T) { tmpDir := t.TempDir() settingsPath := filepath.Join(tmpDir, "settings.json") // No file if hasGeminiBeadsHooks(settingsPath) { t.Error("expected false for missing file") } // Empty file if err := os.WriteFile(settingsPath, []byte("{}"), 0o644); err != nil { t.Fatal(err) } if hasGeminiBeadsHooks(settingsPath) { t.Error("expected false for empty settings") } // With bd prime hook settings := map[string]interface{}{ "hooks": map[string]interface{}{ "SessionStart": []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{"type": "command", "command": "bd prime"}, }, }, }, }, } data, _ := json.Marshal(settings) if err := os.WriteFile(settingsPath, data, 0o644); err != nil { t.Fatal(err) } if !hasGeminiBeadsHooks(settingsPath) { t.Error("expected true for settings with bd prime hook") } // With stealth hook settings["hooks"].(map[string]interface{})["SessionStart"] = []interface{}{ map[string]interface{}{ "matcher": "", "hooks": []interface{}{ map[string]interface{}{"type": "command", "command": "bd prime --stealth"}, }, }, } data, _ = json.Marshal(settings) if err := os.WriteFile(settingsPath, data, 0o644); err != nil { t.Fatal(err) } if !hasGeminiBeadsHooks(settingsPath) { t.Error("expected true for settings with bd prime --stealth hook") } } func TestGeminiSettingsPaths(t *testing.T) { projectPath := geminiProjectSettingsPath("/my/project") if projectPath != "/my/project/.gemini/settings.json" { t.Errorf("unexpected project path: %s", projectPath) } globalPath := geminiGlobalSettingsPath("/home/user") if globalPath != "/home/user/.gemini/settings.json" { t.Errorf("unexpected global path: %s", globalPath) } }