261 lines
7.7 KiB
Go
261 lines
7.7 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package bootstrap
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestEnsureClaudeHook_FreshFile(t *testing.T) {
|
|
s := map[string]any{}
|
|
if !ensureClaudeHook(s, "SessionStart") {
|
|
t.Fatal("expected change on empty settings")
|
|
}
|
|
hooks, ok := s["hooks"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("hooks key missing or wrong type: %v", s)
|
|
}
|
|
ss, ok := hooks["SessionStart"].([]any)
|
|
if !ok || len(ss) != 1 {
|
|
t.Fatalf("SessionStart shape: %v", hooks["SessionStart"])
|
|
}
|
|
entry := ss[0].(map[string]any)
|
|
inner := entry["hooks"].([]any)
|
|
if len(inner) != 1 {
|
|
t.Fatalf("inner hooks: %v", inner)
|
|
}
|
|
h := inner[0].(map[string]any)
|
|
if h["command"] != "veans prime" || h["type"] != "command" {
|
|
t.Fatalf("hook shape: %v", h)
|
|
}
|
|
}
|
|
|
|
func TestEnsureClaudeHook_Idempotent(t *testing.T) {
|
|
s := map[string]any{}
|
|
if !ensureClaudeHook(s, "SessionStart") {
|
|
t.Fatal("first call should change")
|
|
}
|
|
if ensureClaudeHook(s, "SessionStart") {
|
|
t.Fatal("second call should NOT change")
|
|
}
|
|
ss := s["hooks"].(map[string]any)["SessionStart"].([]any)
|
|
if len(ss) != 1 {
|
|
t.Fatalf("expected exactly one entry, got %d: %v", len(ss), ss)
|
|
}
|
|
}
|
|
|
|
func TestEnsureClaudeHook_PreservesOtherHooks(t *testing.T) {
|
|
// Existing settings have an unrelated PreToolUse hook and a SessionStart
|
|
// entry running a different command. The veans entry should be appended,
|
|
// not replace the existing structure.
|
|
raw := []byte(`{
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": "echo hi" } ] }
|
|
],
|
|
"SessionStart": [
|
|
{ "hooks": [ { "type": "command", "command": "other-tool init" } ] }
|
|
]
|
|
},
|
|
"permissions": { "allow": ["Bash"] }
|
|
}`)
|
|
var s map[string]any
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !ensureClaudeHook(s, "SessionStart") {
|
|
t.Fatal("expected change")
|
|
}
|
|
// PreToolUse + permissions untouched.
|
|
if _, ok := s["permissions"]; !ok {
|
|
t.Error("permissions key dropped")
|
|
}
|
|
if pt := s["hooks"].(map[string]any)["PreToolUse"].([]any); len(pt) != 1 {
|
|
t.Errorf("PreToolUse perturbed: %v", pt)
|
|
}
|
|
// SessionStart now has BOTH the original and the veans entry.
|
|
ss := s["hooks"].(map[string]any)["SessionStart"].([]any)
|
|
if len(ss) != 2 {
|
|
t.Fatalf("SessionStart should have 2 entries, got %d", len(ss))
|
|
}
|
|
gotVeans := false
|
|
for _, e := range ss {
|
|
inner := e.(map[string]any)["hooks"].([]any)
|
|
for _, h := range inner {
|
|
if h.(map[string]any)["command"] == "veans prime" {
|
|
gotVeans = true
|
|
}
|
|
}
|
|
}
|
|
if !gotVeans {
|
|
t.Errorf("veans prime not found in merged SessionStart: %v", ss)
|
|
}
|
|
}
|
|
|
|
func TestInstallClaudeCodeHook_CreatesFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path, action, err := installClaudeCodeHook(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action != "Wrote" {
|
|
t.Errorf("first install should say Wrote, got %q", action)
|
|
}
|
|
if !strings.HasSuffix(path, ".claude/settings.json") {
|
|
t.Errorf("unexpected path: %s", path)
|
|
}
|
|
buf, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(buf), `"veans prime"`) {
|
|
t.Errorf("written file missing veans prime command:\n%s", buf)
|
|
}
|
|
// Two-space indent + trailing newline.
|
|
if !strings.HasSuffix(string(buf), "\n") {
|
|
t.Error("written file missing trailing newline")
|
|
}
|
|
if !strings.Contains(string(buf), " \"hooks\"") {
|
|
t.Error("expected 2-space indent")
|
|
}
|
|
}
|
|
|
|
func TestInstallClaudeCodeHook_IdempotentRerun(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, _, err := installClaudeCodeHook(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path, action, err := installClaudeCodeHook(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action != "Already configured" {
|
|
t.Errorf("second install should report Already configured, got %q", action)
|
|
}
|
|
// File hasn't grown duplicate entries.
|
|
buf, _ := os.ReadFile(path)
|
|
if c := strings.Count(string(buf), `"veans prime"`); c != 2 {
|
|
// 2 because both SessionStart and PreCompact reference it once.
|
|
t.Errorf("expected exactly 2 references to veans prime, got %d:\n%s", c, buf)
|
|
}
|
|
}
|
|
|
|
func TestInstallClaudeCodeHook_MergesWithUserSettings(t *testing.T) {
|
|
dir := t.TempDir()
|
|
settingsPath := filepath.Join(dir, ".claude", "settings.json")
|
|
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := `{
|
|
"model": "claude-opus-4-7",
|
|
"hooks": {
|
|
"SessionStart": [
|
|
{ "hooks": [ { "type": "command", "command": "other-tool" } ] }
|
|
]
|
|
}
|
|
}`
|
|
if err := os.WriteFile(settingsPath, []byte(existing), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, action, err := installClaudeCodeHook(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action != "Updated" {
|
|
t.Errorf("merging into existing file should say Updated, got %q", action)
|
|
}
|
|
buf, _ := os.ReadFile(settingsPath)
|
|
out := string(buf)
|
|
for _, want := range []string{`"model": "claude-opus-4-7"`, `"other-tool"`, `"veans prime"`} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("merged file missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInstallOpenCodeHook_CreatesAndIdempotent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path, action, err := installOpenCodeHook(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action != "Wrote" {
|
|
t.Errorf("first install should say Wrote, got %q", action)
|
|
}
|
|
buf, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"VeansPrime", "veans prime", "session.start", "compact.before"} {
|
|
if !strings.Contains(string(buf), want) {
|
|
t.Errorf("opencode file missing %q:\n%s", want, buf)
|
|
}
|
|
}
|
|
|
|
// Re-run leaves the file alone — we don't merge TS by hand.
|
|
_, action2, err := installOpenCodeHook(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action2 != "Already configured" {
|
|
t.Errorf("rerun should say Already configured, got %q", action2)
|
|
}
|
|
}
|
|
|
|
func TestOfferAgentHooks_NoHooks(t *testing.T) {
|
|
choices, err := offerAgentHooks(nil, nil, AgentHookChoice{}, false, false, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if choices.ClaudeCode || choices.OpenCode {
|
|
t.Errorf("NoHooks should return empty: %+v", choices)
|
|
}
|
|
}
|
|
|
|
func TestOfferAgentHooks_FlagsBypassPrompt(t *testing.T) {
|
|
// Both flags set explicitly — no prompts.
|
|
p := &scriptedPrompter{} // would panic with out-of-range on any ReadLine
|
|
choices, err := offerAgentHooks(p, nopWriter{}, AgentHookChoice{ClaudeCode: true, OpenCode: false}, true, true, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !choices.ClaudeCode || choices.OpenCode {
|
|
t.Errorf("expected ClaudeCode=true, OpenCode=false; got %+v", choices)
|
|
}
|
|
}
|
|
|
|
func TestOfferAgentHooks_PromptsWhenFlagsUnset(t *testing.T) {
|
|
// User accepts Claude default (Y), declines OpenCode.
|
|
p := &scriptedPrompter{answers: []string{"", "n"}}
|
|
choices, err := offerAgentHooks(p, nopWriter{}, AgentHookChoice{}, false, false, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !choices.ClaudeCode || choices.OpenCode {
|
|
t.Errorf("expected ClaudeCode=true OpenCode=false, got %+v", choices)
|
|
}
|
|
}
|
|
|
|
// nopWriter discards everything; lets tests run prompts without console noise.
|
|
type nopWriter struct{}
|
|
|
|
func (nopWriter) Write(p []byte) (int, error) { return len(p), nil }
|