//go:build smoke // Package e2e contains smoke and regression tests for cc-connect. // These tests verify core functionality using real components where possible, // and mocks where necessary (e.g., platform network calls). package e2e import ( "context" "os" "path/filepath" "strings" "testing" "time" "github.com/chenhg5/cc-connect/config" "github.com/chenhg5/cc-connect/core" "github.com/chenhg5/cc-connect/tests/mocks" "github.com/chenhg5/cc-connect/tests/mocks/fake" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) // --------------------------------------------------------------------------- // T-100: Config Loading Smoke Test // --------------------------------------------------------------------------- func TestSmoke_ConfigLoading(t *testing.T) { // Create a temporary config file tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.toml") configContent := ` data_dir = "~/.cc-connect" [[projects]] name = "test-project" [projects.agent] type = "claudecode" [[projects.platforms]] type = "feishu" [log] level = "info" ` err := os.WriteFile(configPath, []byte(configContent), 0644) require.NoError(t, err) // Load the config config.ConfigPath = configPath cfg, err := config.Load(configPath) require.NoError(t, err) // Verify basic config fields assert.Equal(t, "~/.cc-connect", cfg.DataDir) assert.Len(t, cfg.Projects, 1) assert.Equal(t, "test-project", cfg.Projects[0].Name) assert.Equal(t, "claudecode", cfg.Projects[0].Agent.Type) t.Log("Config loading: PASS") } func TestSmoke_ConfigLoadingInvalid(t *testing.T) { // Test that invalid config is properly rejected tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "invalid.toml") // Write invalid TOML err := os.WriteFile(configPath, []byte("invalid toml content ="), 0644) require.NoError(t, err) _, err = config.Load(configPath) assert.Error(t, err) t.Log("Config loading (invalid): PASS") } // --------------------------------------------------------------------------- // T-101: All Agents Initialization Smoke Test // --------------------------------------------------------------------------- func TestSmoke_AllAgentsInit(t *testing.T) { // Verify all registered agents can be listed ctx := context.Background() // Get list of registered agent factories // Note: This tests the factory registration, not actual CLI existence t.Log("Registered agents via factory:", listRegisteredAgents()) // Verify we have agents registered (at least the ones in the codebase) assert.NotEmpty(t, listRegisteredAgents(), "should have at least one agent registered") // Try to create each registered agent with minimal opts for _, agentName := range listRegisteredAgents() { opts := map[string]any{ "project_dir": t.TempDir(), } agent, err := core.CreateAgent(agentName, opts) if err != nil { t.Logf("Agent %s creation: %v (may require CLI)", agentName, err) continue } assert.NotNil(t, agent) assert.Equal(t, agentName, agent.Name()) // Clean up agent.Stop() t.Logf("Agent %s: initialized successfully", agentName) } // Use ctx to avoid compiler warning assert.NotNil(t, ctx) t.Log("All agents init: PASS") } func listRegisteredAgents() []string { // This requires access to the internal registry // We'll test via the factory pattern agents := []string{ "claudecode", "codex", "cursor", "gemini", "iflow", "opencode", "pi", "qoder", } return agents } // --------------------------------------------------------------------------- // T-102: All Platforms Initialization Smoke Test // --------------------------------------------------------------------------- func TestSmoke_AllPlatformsInit(t *testing.T) { // Verify all registered platforms can be listed t.Log("Registered platforms:", listRegisteredPlatforms()) // Verify we have platforms registered assert.NotEmpty(t, listRegisteredPlatforms(), "should have at least one platform registered") // Try to create each registered platform with minimal opts for _, platformName := range listRegisteredPlatforms() { opts := map[string]any{ "enabled": true, } platform, err := core.CreatePlatform(platformName, opts) if err != nil { t.Logf("Platform %s creation: %v", platformName, err) continue } assert.NotNil(t, platform) assert.Equal(t, platformName, platform.Name()) // Clean up - don't actually start, just verify creation t.Logf("Platform %s: created successfully", platformName) } t.Log("All platforms init: PASS") } func listRegisteredPlatforms() []string { platforms := []string{ "feishu", "telegram", "discord", "slack", "dingtalk", "wecom", "qq", "qqbot", "line", } return platforms } // --------------------------------------------------------------------------- // T-103: Session Management Smoke Test // --------------------------------------------------------------------------- func TestSmoke_SessionManagement(t *testing.T) { ctx := context.Background() // Create a fake agent for session testing agent := fake.NewFakeAgent("test-agent") // Test session creation session, err := agent.StartSession(ctx, "test-session-001") require.NoError(t, err) assert.NotNil(t, session) assert.Equal(t, "test-session-001", session.CurrentSessionID()) assert.True(t, session.Alive()) // Test session list sessions, err := agent.ListSessions(ctx) require.NoError(t, err) assert.NotEmpty(t, sessions) // Test sending a message err = session.Send("Hello", nil, nil) require.NoError(t, err) // Test session is still alive assert.True(t, session.Alive()) // Test session close err = session.Close() require.NoError(t, err) assert.False(t, session.Alive()) t.Log("Session management: PASS") } func TestSmoke_SessionMessageFlow(t *testing.T) { ctx := context.Background() // Create a fake agent session with predefined responses session := fake.NewFakeAgentSession("test-session-002") session.AddTextEvent("Thinking...").AddResultEvent("Final answer.") agent := fake.NewFakeAgentWithSession("test-agent", "test-session-002", session) // Start session started, err := agent.StartSession(ctx, "test-session-002") require.NoError(t, err) assert.Equal(t, "test-session-002", started.CurrentSessionID()) // Send message err = started.Send("What is 2+2?", nil, nil) require.NoError(t, err) // Collect events var events []core.Event for e := range started.Events() { events = append(events, e) if e.Done { break } } // Verify events assert.NotEmpty(t, events) assert.Equal(t, core.EventType("text"), events[0].Type) t.Log("Session message flow: PASS") } // --------------------------------------------------------------------------- // T-104: Command Parsing Smoke Test // --------------------------------------------------------------------------- func TestSmoke_CommandParsing(t *testing.T) { registry := core.NewCommandRegistry() // Add some test commands registry.Add("test", "Test command", "echo {{1}}", "", "", "test") // Test command resolution cmd, ok := registry.Resolve("test") require.True(t, ok) assert.NotNil(t, cmd) assert.Equal(t, "test", cmd.Name) assert.Equal(t, "Test command", cmd.Description) // Test built-in commands registry.Add("help", "Show help", "help prompt", "", "", "builtin") cmd, ok = registry.Resolve("help") require.True(t, ok) assert.Equal(t, "help", cmd.Name) // Test command not found cmd, ok = registry.Resolve("nonexistent") assert.False(t, ok) assert.Nil(t, cmd) t.Log("Command parsing: PASS") } func TestSmoke_CommandRegistryList(t *testing.T) { registry := core.NewCommandRegistry() // Add multiple commands registry.Add("cmd1", "Command 1", "{{1}}", "", "", "test") registry.Add("cmd2", "Command 2", "{{2}}", "", "", "test") registry.Add("alias", "Alias for cmd1", "", "", "", "alias") // List all commands commands := registry.ListAll() assert.Len(t, commands, 3) // Test clear by source registry.ClearSource("test") commands = registry.ListAll() assert.Len(t, commands, 1) // only alias should remain t.Log("Command registry list: PASS") } // --------------------------------------------------------------------------- // T-105: Agent ↔ Platform Message Flow Smoke Test // --------------------------------------------------------------------------- func TestSmoke_MessageFlow(t *testing.T) { ctx := context.Background() // Create mock platform mockPlatform := new(mocks.MockPlatform) mockPlatform.On("Name").Return("test-platform") mockPlatform.On("Start", mock.Anything).Return(nil) mockPlatform.On("Stop").Return(nil) // Create mock agent session with events events := []core.Event{ {Type: core.EventThinking, Content: "Processing..."}, {Type: core.EventText, Content: "I am thinking..."}, {Type: core.EventResult, Content: "Final response.", Done: true}, } mockSession := mocks.NewMockAgentSessionWithEvents("session-001", events) // Create mock agent mockAgent := new(mocks.MockAgent) mockAgent.On("StartSession", ctx, "session-001").Return(mockSession, nil) // Test message handler handler := fake.NewTestMessageHandler() // Start platform with handler err := mockPlatform.Start(handler.Handle) require.NoError(t, err) // Simulate message flow: platform receives message → routed to agent // (This is a simplified test - real flow goes through Engine) incomingMessage := fake.TestMessageWithContent("Hello agent") // Simulate platform passing message to handler handler.Handle(mockPlatform, incomingMessage) // Verify message was received messages := handler.GetMessages() require.Len(t, messages, 1) assert.Equal(t, "Hello agent", messages[0].Content) // Test agent session responds session, err := mockAgent.StartSession(ctx, "session-001") require.NoError(t, err) // Send message to agent err = session.Send("Hello agent", nil, nil) require.NoError(t, err) // Verify mock calls happened (only agent, not platform since we didn't call Name/Stop) mockAgent.AssertExpectations(t) t.Log("Message flow: PASS") } func TestSmoke_PlatformReply(t *testing.T) { ctx := context.Background() // Create mock platform that records replies mockPlatform := new(mocks.MockPlatform) // Expect Reply call mockPlatform.On("Reply", ctx, mock.Anything, "Hello from agent").Return(nil) // Simulate platform reply err := mockPlatform.Reply(ctx, nil, "Hello from agent") require.NoError(t, err) mockPlatform.AssertExpectations(t) t.Log("Platform reply: PASS") } func TestSmoke_EventTypes(t *testing.T) { // Test all event types can be created events := []core.Event{ {Type: core.EventText, Content: "text content"}, {Type: core.EventThinking, Content: "thinking..."}, {Type: core.EventToolUse, ToolName: "Bash", ToolInput: "ls"}, {Type: core.EventToolResult, ToolResult: "file1.go"}, {Type: core.EventResult, Content: "final result", Done: true}, {Type: core.EventError, Error: context.DeadlineExceeded, Done: true}, { Type: core.EventPermissionRequest, ToolName: "Bash", ToolInput: "rm -rf /", RequestID: "req-001", }, } for _, e := range events { assert.NotEmpty(t, string(e.Type)) } t.Log("Event types: PASS") } // --------------------------------------------------------------------------- // T-107: Multi-Workspace Switch (P1, but quick to add) // --------------------------------------------------------------------------- func TestSmoke_WorkspaceSwitch(t *testing.T) { // Create simple workspace state maps to verify isolation concept ws1 := map[string]string{ "id": "workspace-1", "session": "session-A", "agent": "claudecode", } ws2 := map[string]string{ "id": "workspace-2", "session": "session-B", "agent": "gemini", } assert.Equal(t, "workspace-1", ws1["id"]) assert.Equal(t, "workspace-2", ws2["id"]) assert.NotEqual(t, ws1["session"], ws2["session"], "workspaces should have independent sessions") t.Log("Workspace switch: PASS") } // --------------------------------------------------------------------------- // T-108: Rate Limiter Basic (P1, but quick to add) // --------------------------------------------------------------------------- func TestSmoke_RateLimiter(t *testing.T) { // Create a rate limiter: 5 messages per 60 seconds rl := core.NewRateLimiter(5, 60*time.Second) defer rl.Stop() // Should allow messages up to limit for i := 0; i < 5; i++ { allowed := rl.Allow("user1") assert.True(t, allowed, "message %d should be allowed", i+1) } // Should block after limit allowed := rl.Allow("user1") assert.False(t, allowed, "6th message should be blocked") // Different user should be allowed allowed = rl.Allow("user2") assert.True(t, allowed, "different user should be allowed") t.Log("Rate limiter: PASS") } // --------------------------------------------------------------------------- // T-111: Markdown 渲染冒烟测试 // --------------------------------------------------------------------------- func TestSmoke_MarkdownRender(t *testing.T) { // Test basic card with markdown rendering card := core.NewCard(). Title("Test Card", "blue"). Markdown("## Heading\nThis is **bold** and *italic* text."). Markdown("- Item 1\n- Item 2\n- Item 3"). Markdown("1. Numbered item\n2. Another item"). Markdown("> Blockquote text"). Markdown("`inline code` and ```code block```"). Build() // Verify card structure assert.NotNil(t, card) assert.NotNil(t, card.Header) assert.Equal(t, "Test Card", card.Header.Title) assert.Equal(t, "blue", card.Header.Color) // Count markdown elements (should be 5) var markdownCount int for _, elem := range card.Elements { if _, ok := elem.(core.CardMarkdown); ok { markdownCount++ } } assert.Equal(t, 5, markdownCount, "should have 5 markdown elements") // Test text fallback rendering text := card.RenderText() assert.Contains(t, text, "Test Card") assert.Contains(t, text, "Heading") assert.Contains(t, text, "bold") assert.Contains(t, text, "italic") assert.Contains(t, text, "Item 1") assert.Contains(t, text, "Blockquote") assert.Contains(t, text, "inline code") // Test card with only divider card2 := core.NewCard(). Markdown("Before divider"). Divider(). Markdown("After divider"). Build() text2 := card2.RenderText() assert.Contains(t, text2, "Before divider") assert.Contains(t, text2, "---") assert.Contains(t, text2, "After divider") // Test card with select element card3 := core.NewCard(). Title("Select Card", "green"). Select("Choose an option", []core.CardSelectOption{ {Text: "Option A", Value: "a"}, {Text: "Option B", Value: "b"}, }, "a"). Build() text3 := card3.RenderText() assert.Contains(t, text3, "Choose an option") assert.Contains(t, text3, "Option A") assert.Contains(t, text3, "Option B") // Test HasButtons assert.False(t, card.HasButtons()) assert.True(t, core.NewCard().Buttons(core.DefaultBtn("Test", "act:/test")).Build().HasButtons()) t.Log("Markdown render: PASS") } // --------------------------------------------------------------------------- // T-112: Webhook 注册和回调冒烟测试 // --------------------------------------------------------------------------- func TestSmoke_WebhookCallback(t *testing.T) { // Test webhook callback data structure type webhookCallback struct { Action string `json:"action"` SessionID string `json:"session_id"` Data map[string]string `json:"data"` } // Simulate callback parsing callbackData := `{"action":"callback","session_id":"test-001","data":{"button":"confirm"}}` // Verify callback structure can be marshaled cb := struct { Action string `json:"action"` SessionID string `json:"session_id"` Data map[string]string `json:"data"` }{ Action: "callback", SessionID: "test-001", Data: map[string]string{"button": "confirm"}, } assert.Equal(t, "callback", cb.Action) assert.Equal(t, "test-001", cb.SessionID) assert.Equal(t, "confirm", cb.Data["button"]) // Verify the raw data can be parsed assert.Contains(t, callbackData, "callback") assert.Contains(t, callbackData, "test-001") // Test action routing actions := []string{"callback", "act:/confirm", "act:/cancel", "act:/delete"} for _, action := range actions { assert.NotEmpty(t, action) // Action should be either callback or act:/ prefix isValid := action == "callback" || strings.HasPrefix(action, "act:/") assert.True(t, isValid, "action %s should be valid", action) } // Use the callbackData to avoid compiler warning assert.NotEmpty(t, callbackData) t.Log("Webhook callback: PASS") }