package gemini import ( "context" "encoding/json" "fmt" "path/filepath" "strings" "testing" "time" "github.com/chenhg5/cc-connect/core" ) // sanitizeFileName mirrors the logic in geminiSession.Send for file name sanitization. func sanitizeFileName(fileName string, index int) string { fname := filepath.Base(fileName) if fname == "" || fname == "." || fname == ".." { fname = fmt.Sprintf("cc-connect-file-%d", index) } return fname } func TestSanitizeFileName(t *testing.T) { tests := []struct { name string input string index int wantSafe bool want string }{ {"normal file", "report.pdf", 0, true, "report.pdf"}, {"path traversal", "../../etc/passwd", 0, true, "passwd"}, {"deep traversal", "../../../tmp/evil.sh", 1, true, "evil.sh"}, {"absolute path", "/etc/shadow", 0, true, "shadow"}, {"empty name", "", 2, true, "cc-connect-file-2"}, {"dot only", ".", 3, true, "cc-connect-file-3"}, {"double dot", "..", 4, true, "cc-connect-file-4"}, {"subdir file", "subdir/file.txt", 0, true, "file.txt"}, {"slash path", "dir/subdir/evil.dll", 0, true, "evil.dll"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := sanitizeFileName(tt.input, tt.index) if tt.want != "" && got != tt.want { t.Errorf("sanitizeFileName(%q, %d) = %q, want %q", tt.input, tt.index, got, tt.want) } if strings.Contains(got, "..") || strings.Contains(got, "/") { t.Errorf("sanitizeFileName(%q, %d) = %q — still contains path traversal chars", tt.input, tt.index, got) } }) } } // drainEvents reads all events from the channel until it blocks for the given timeout. func drainEvents(ch <-chan core.Event, timeout time.Duration) []core.Event { var events []core.Event for { select { case evt, ok := <-ch: if !ok { return events } events = append(events, evt) case <-time.After(timeout): return events } } } func TestHandleMessage_DeltaEmitsEventTextImmediately(t *testing.T) { gs := &geminiSession{ events: make(chan core.Event, 64), ctx: context.Background(), } // Delta message should emit EventText immediately gs.handleEvent(map[string]any{ "type": "message", "role": "assistant", "content": "Hello ", "delta": true, }) gs.handleEvent(map[string]any{ "type": "message", "role": "assistant", "content": "world!", "delta": true, }) events := drainEvents(gs.events, 50*time.Millisecond) if len(events) != 2 { t.Fatalf("expected 2 events, got %d", len(events)) } if events[0].Type != core.EventText || events[0].Content != "Hello " { t.Errorf("event 0: expected EventText 'Hello ', got %v %q", events[0].Type, events[0].Content) } if events[1].Type != core.EventText || events[1].Content != "world!" { t.Errorf("event 1: expected EventText 'world!', got %v %q", events[1].Type, events[1].Content) } // No pending messages should be buffered if len(gs.pendingMsgs) != 0 { t.Errorf("expected 0 pending messages, got %d", len(gs.pendingMsgs)) } } func TestHandleMessage_NonDeltaBuffered(t *testing.T) { gs := &geminiSession{ events: make(chan core.Event, 64), ctx: context.Background(), } // Non-delta message should be buffered (no immediate event) gs.handleEvent(map[string]any{ "type": "message", "role": "assistant", "content": "Thinking about this...", }) events := drainEvents(gs.events, 50*time.Millisecond) if len(events) != 0 { t.Fatalf("expected 0 immediate events for non-delta message, got %d", len(events)) } if len(gs.pendingMsgs) != 1 || gs.pendingMsgs[0] != "Thinking about this..." { t.Errorf("expected 1 pending message, got %v", gs.pendingMsgs) } } func TestHandleMessage_NonDeltaFlushedAsThinkingOnToolUse(t *testing.T) { gs := &geminiSession{ events: make(chan core.Event, 64), ctx: context.Background(), } // Buffer a non-delta message gs.handleEvent(map[string]any{ "type": "message", "role": "assistant", "content": "Let me check...", }) // tool_use should flush it as thinking gs.handleEvent(map[string]any{ "type": "tool_use", "tool_name": "shell", "tool_id": "t1", "parameters": map[string]any{ "command": "ls", }, }) events := drainEvents(gs.events, 50*time.Millisecond) if len(events) != 2 { t.Fatalf("expected 2 events, got %d", len(events)) } if events[0].Type != core.EventThinking || events[0].Content != "Let me check..." { t.Errorf("event 0: expected EventThinking, got %v %q", events[0].Type, events[0].Content) } if events[1].Type != core.EventToolUse { t.Errorf("event 1: expected EventToolUse, got %v", events[1].Type) } } func TestHandleMessage_NonDeltaFlushedAsTextOnResult(t *testing.T) { gs := &geminiSession{ events: make(chan core.Event, 64), ctx: context.Background(), } // Buffer a non-delta message gs.handleEvent(map[string]any{ "type": "message", "role": "assistant", "content": "Here is the result.", }) // result should flush it as text gs.handleEvent(map[string]any{ "type": "result", "status": "success", }) events := drainEvents(gs.events, 50*time.Millisecond) if len(events) != 2 { t.Fatalf("expected 2 events, got %d", len(events)) } if events[0].Type != core.EventText || events[0].Content != "Here is the result." { t.Errorf("event 0: expected EventText, got %v %q", events[0].Type, events[0].Content) } if events[1].Type != core.EventResult { t.Errorf("event 1: expected EventResult, got %v", events[1].Type) } } func TestHandleMessage_UserMessagesIgnored(t *testing.T) { gs := &geminiSession{ events: make(chan core.Event, 64), ctx: context.Background(), } gs.handleEvent(map[string]any{ "type": "message", "role": "user", "content": "Hello agent", "delta": true, }) events := drainEvents(gs.events, 50*time.Millisecond) if len(events) != 0 { t.Fatalf("expected 0 events for user message, got %d", len(events)) } } func TestHandleMessage_EmptyContentIgnored(t *testing.T) { gs := &geminiSession{ events: make(chan core.Event, 64), ctx: context.Background(), } gs.handleEvent(map[string]any{ "type": "message", "role": "assistant", "content": "", "delta": true, }) events := drainEvents(gs.events, 50*time.Millisecond) if len(events) != 0 { t.Fatalf("expected 0 events for empty content, got %d", len(events)) } } func TestHandleMessage_MixedDeltaAndNonDelta(t *testing.T) { gs := &geminiSession{ events: make(chan core.Event, 64), ctx: context.Background(), } // Simulate a realistic Gemini CLI output sequence: // 1. non-delta thinking message // 2. tool_use flushes thinking // 3. tool_result // 4. delta streaming responses // 5. result gs.handleEvent(map[string]any{ "type": "message", "role": "assistant", "content": "Let me look at the files.", }) gs.handleEvent(map[string]any{ "type": "tool_use", "tool_name": "shell", "tool_id": "t1", "parameters": map[string]any{"command": "ls"}, }) gs.handleEvent(map[string]any{ "type": "tool_result", "tool_id": "t1", "status": "success", "output": "file1.txt\nfile2.txt", }) gs.handleEvent(map[string]any{ "type": "message", "role": "assistant", "content": "Here are ", "delta": true, }) gs.handleEvent(map[string]any{ "type": "message", "role": "assistant", "content": "the files.", "delta": true, }) gs.handleEvent(map[string]any{ "type": "result", "status": "success", }) events := drainEvents(gs.events, 50*time.Millisecond) // Expected sequence: EventThinking, EventToolUse, EventToolResult, EventText, EventText, EventResult if len(events) != 6 { var types []string for _, e := range events { types = append(types, string(e.Type)) } t.Fatalf("expected 6 events, got %d: %v", len(events), types) } expects := []struct { typ core.EventType content string }{ {core.EventThinking, "Let me look at the files."}, {core.EventToolUse, ""}, {core.EventToolResult, ""}, {core.EventText, "Here are "}, {core.EventText, "the files."}, {core.EventResult, ""}, } for i, exp := range expects { if events[i].Type != exp.typ { t.Errorf("event %d: expected type %v, got %v", i, exp.typ, events[i].Type) } if exp.content != "" && events[i].Content != exp.content { t.Errorf("event %d: expected content %q, got %q", i, exp.content, events[i].Content) } } } func TestHandleInit_StoresSessionID(t *testing.T) { gs := &geminiSession{ events: make(chan core.Event, 64), ctx: context.Background(), } gs.handleEvent(map[string]any{ "type": "init", "session_id": "abc123", "model": "gemini-2.0-flash", }) if sid := gs.CurrentSessionID(); sid != "abc123" { t.Errorf("expected session_id abc123, got %q", sid) } events := drainEvents(gs.events, 50*time.Millisecond) if len(events) != 1 || events[0].Type != core.EventText { t.Errorf("expected 1 EventText from init, got %v", events) } } func TestHandleError_EmitsEventError(t *testing.T) { gs := &geminiSession{ events: make(chan core.Event, 64), ctx: context.Background(), } gs.handleEvent(map[string]any{ "type": "error", "severity": "error", "message": "something broke", }) events := drainEvents(gs.events, 50*time.Millisecond) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Type != core.EventError { t.Errorf("expected EventError, got %v", events[0].Type) } if !strings.Contains(events[0].Error.Error(), "something broke") { t.Errorf("error should contain message, got %v", events[0].Error) } } func TestFormatToolParams(t *testing.T) { tests := []struct { name string toolName string params map[string]any want string }{ {"shell command", "shell", map[string]any{"command": "ls -la"}, "ls -la"}, {"Bash command", "Bash", map[string]any{"command": "echo hello"}, "echo hello"}, {"write file path only", "write_file", map[string]any{"file_path": "/tmp/test.txt"}, "/tmp/test.txt"}, {"write file with content", "write_file", map[string]any{"file_path": "/tmp/test.txt", "content": "hello world"}, "`/tmp/test.txt`\n```\nhello world\n```"}, {"write file full content", "write_file", map[string]any{"file_path": "/tmp/test.txt", "content": strings.Repeat("x", 200)}, "`/tmp/test.txt`\n```\n" + strings.Repeat("x", 200) + "\n```"}, {"read file path", "read_file", map[string]any{"path": "/tmp/test.txt"}, "/tmp/test.txt"}, {"replace single line", "replace", map[string]any{"file_path": "/tmp/test.go", "old_string": "foo", "new_string": "bar"}, "`/tmp/test.go`\n```diff\n- foo\n+ bar\n```"}, {"replace with context", "replace", map[string]any{"file_path": "/tmp/test.go", "old_string": "func foo() {\n return 1\n}", "new_string": "func foo() {\n return 2\n}"}, "`/tmp/test.go`\n```diff\n func foo() {\n- return 1\n+ return 2\n }\n```"}, {"list directory path", "list_directory", map[string]any{"path": "/home/user"}, "/home/user"}, {"list directory dir_path", "list_directory", map[string]any{"dir_path": "/home/user"}, "/home/user"}, {"web fetch prompt", "web_fetch", map[string]any{"prompt": "https://example.com fetch this page"}, "https://example.com fetch this page"}, {"web fetch url fallback", "web_fetch", map[string]any{"url": "https://example.com"}, "https://example.com"}, {"search query", "google_web_search", map[string]any{"query": "golang testing"}, "golang testing"}, {"activate skill", "activate_skill", map[string]any{"name": "obsidian-markdown"}, "obsidian-markdown"}, {"search code", "search_code", map[string]any{"query": "func main"}, "func main"}, {"Grep pattern", "Grep", map[string]any{"pattern": "TODO"}, "TODO"}, {"grep_search pattern", "grep_search", map[string]any{"pattern": "TODO"}, "TODO"}, {"glob lowercase", "glob", map[string]any{"pattern": "*.go"}, "*.go"}, {"save memory", "save_memory", map[string]any{"fact": "user prefers dark mode"}, "user prefers dark mode"}, {"ask user", "ask_user", map[string]any{"questions": []any{map[string]any{"question": "Which DB?", "header": "DB", "type": "choice"}}}, "Which DB?"}, {"enter plan mode", "enter_plan_mode", map[string]any{"reason": "complex task"}, "complex task"}, {"exit plan mode", "exit_plan_mode", map[string]any{"plan_path": "/tmp/plan.md"}, "/tmp/plan.md"}, {"nil params", "shell", nil, ""}, {"unknown single key", "custom_tool", map[string]any{"key": "value"}, "key: value"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := formatToolParams(tt.toolName, tt.params) if got != tt.want { t.Errorf("formatToolParams(%q, %v) = %q, want %q", tt.toolName, tt.params, got, tt.want) } }) } } func TestSlugify(t *testing.T) { tests := []struct { input string want string }{ {"cc-connect", "cc-connect"}, {"Daily", "daily"}, {"My Project", "my-project"}, {"hello_world", "hello-world"}, {"Test.123", "test-123"}, {"---weird---", "weird"}, {"", "project"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := slugify(tt.input) if got != tt.want { t.Errorf("slugify(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestSessionMessage_TextContent(t *testing.T) { // Test plain string content m1 := sessionMessage{Type: "user", RawContent: json.RawMessage(`"hello world"`)} if got := m1.textContent(); got != "hello world" { t.Errorf("string content: got %q, want %q", got, "hello world") } // Test array of parts content m2 := sessionMessage{Type: "user", RawContent: json.RawMessage(`[{"text":"line 1"},{"text":"line 2"}]`)} if got := m2.textContent(); got != "line 1\nline 2" { t.Errorf("array content: got %q, want %q", got, "line 1\nline 2") } // Test empty content m3 := sessionMessage{Type: "user", RawContent: nil} if got := m3.textContent(); got != "" { t.Errorf("nil content: got %q, want empty", got) } } func TestComputeLineDiff(t *testing.T) { tests := []struct { name string old string new_ string want string }{ { "single line fully different", "foo", "bar", "- foo\n+ bar", }, { "common prefix and suffix", "func foo() {\n return 1\n}", "func foo() {\n return 2\n}", " func foo() {\n- return 1\n+ return 2\n }", }, { "large context ellipsis", "a\nb\nc\nd\nold\ne\nf\ng\nh", "a\nb\nc\nd\nnew\ne\nf\ng\nh", " ...\n d\n- old\n+ new\n e\n ...", }, { "common prefix only", "header\nold1\nold2", "header\nnew1", " header\n- old1\n- old2\n+ new1", }, { "common suffix only", "old1\nfooter", "new1\nnew2\nfooter", "- old1\n+ new1\n+ new2\n footer", }, { "identical", "same\nlines", "same\nlines", "", // prefixLen covers all lines, no diff }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := computeLineDiff(tt.old, tt.new_) if got != tt.want { t.Errorf("computeLineDiff:\n old=%q\n new=%q\n got=%q\n want=%q", tt.old, tt.new_, got, tt.want) } }) } } func TestGeminiSession_ContinueSessionTreatedAsFresh(t *testing.T) { s, err := newGeminiSession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil, 0) if err != nil { t.Fatalf("newGeminiSession: %v", err) } defer s.Close() if got := s.CurrentSessionID(); got != "" { t.Errorf("ContinueSession should be treated as fresh: chatID = %q, want empty", got) } }