初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
//go:build blackbox
// This file registers all agent factories by importing the agent packages.
// Without these blank imports, core.CreateAgent would return "unknown agent".
// Each import triggers the package's init() function which calls core.RegisterAgent.
package helper
import (
_ "github.com/chenhg5/cc-connect/agent/claudecode"
_ "github.com/chenhg5/cc-connect/agent/codex"
_ "github.com/chenhg5/cc-connect/agent/cursor"
_ "github.com/chenhg5/cc-connect/agent/gemini"
_ "github.com/chenhg5/cc-connect/agent/opencode"
_ "github.com/chenhg5/cc-connect/agent/qoder"
)
+443
View File
@@ -0,0 +1,443 @@
// Package helper provides test environment setup for blackbox tests.
//
// BlackboxEnv wraps a real cc-connect Engine, a real Agent (Claude Code,
// Codex, etc.), and a MockPlatform. Tests inject messages and assert on what
// the platform receives — exactly what a real user would see.
package helper
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
bbplatform "github.com/chenhg5/cc-connect/tests/blackbox/platform"
)
const (
// DefaultReplyTimeout is generous because real agents (Claude Code, etc.)
// take 10-60 seconds to respond. Tests using the default timeout accept
// that slowness is the price of real testing.
DefaultReplyTimeout = 120 * time.Second
// DefaultUser and DefaultChat are used by Send / helpers that don't need
// multi-user scenarios.
DefaultUser = "user1"
DefaultChat = "chat1"
)
// Env is a fully wired blackbox test environment.
// Always create via NewEnv — never construct directly.
type Env struct {
T *testing.T
Platform *bbplatform.MockPlatform
Engine *core.Engine
WorkDir string
DataDir string
// agentType is stored for diagnostic messages only.
agentType string
}
// NewEnv creates a blackbox test environment with a real agent.
//
// agentType selects the agent: "claudecode", "codex", "gemini", etc.
// The test is skipped (not failed) when:
// - the agent CLI binary is not in PATH
// - the required API credentials are missing from the environment
//
// A successful NewEnv call registers a t.Cleanup that stops the engine.
func NewEnv(t *testing.T, agentType string) *Env {
return NewEnvWithSetup(t, agentType, nil)
}
// NewEnvWithSetup is like NewEnv but accepts an optional setup function that
// is called after the engine is created but BEFORE engine.Start(). Use this
// to configure engine options (display, banned words, disabled commands, etc.)
// that must be set before the engine begins processing messages.
//
// Example:
//
// env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
// e.SetShowContextIndicator(false)
// e.SetBannedWords([]string{"secret"})
// })
func NewEnvWithSetup(t *testing.T, agentType string, setup func(*core.Engine)) *Env {
t.Helper()
requireAgent(t, agentType)
workDir := t.TempDir()
dataDir := t.TempDir()
opts := map[string]any{
"work_dir": workDir,
}
// Cursor requires force/yolo mode in automated tests to bypass interactive
// workspace-trust prompts. Without --force the agent exits immediately.
if agentType == "cursor" {
opts["mode"] = "force"
}
applyProviderFromEnv(t, agentType, opts)
agent, err := core.CreateAgent(agentType, opts)
if err != nil {
t.Skipf("blackbox skip: cannot create %s agent: %v", agentType, err)
}
wireProviders(t, agentType, agent)
mp := bbplatform.New(agentType + "-mock")
sessPath := filepath.Join(dataDir, "sessions.json")
engine := core.NewEngine("blackbox-"+t.Name(), agent, []core.Platform{mp}, sessPath, core.LangEnglish)
if setup != nil {
setup(engine)
}
if err := engine.Start(); err != nil {
t.Fatalf("blackbox: engine.Start failed: %v", err)
}
t.Cleanup(func() {
engine.Stop()
agent.Stop()
})
return &Env{
T: t,
Platform: mp,
Engine: engine,
WorkDir: workDir,
DataDir: dataDir,
agentType: agentType,
}
}
// ── Message helpers ───────────────────────────────────────────────────────────
// Send injects a message from DefaultUser in DefaultChat and waits up to
// DefaultReplyTimeout for the first reply. Fails the test on timeout.
func (e *Env) Send(content string) *bbplatform.SentMessage {
e.T.Helper()
return e.SendAs(DefaultUser, DefaultChat, content, DefaultReplyTimeout)
}
// SendWithTimeout is like Send but uses a custom timeout.
func (e *Env) SendWithTimeout(content string, timeout time.Duration) *bbplatform.SentMessage {
e.T.Helper()
return e.SendAs(DefaultUser, DefaultChat, content, timeout)
}
// SendAs injects a message from a specific user/chat pair and waits for a
// reply. Use this for multi-user isolation tests.
func (e *Env) SendAs(userID, chatID, content string, timeout time.Duration) *bbplatform.SentMessage {
e.T.Helper()
before := e.Platform.MessageCount()
e.Platform.InjectMessage(userID, chatID, content)
reply := e.Platform.WaitForReply(before, timeout)
if reply == nil {
e.T.Fatalf(
"blackbox timeout (%v): no reply to %q from %s/%s\nagent=%s\nall messages so far:\n%s",
timeout, content, userID, chatID, e.agentType,
e.Platform.AllText(),
)
}
return reply
}
// SendComplete injects a message and waits for the full agent turn to finish
// (i.e., the message stream is stable for idlePeriod). Returns all messages
// sent during the turn. Use this instead of Send when you need the turn to be
// fully done before sending the next message (multi-turn context tests).
func (e *Env) SendComplete(content string) []*bbplatform.SentMessage {
e.T.Helper()
return e.SendCompleteAs(DefaultUser, DefaultChat, content, 5*time.Second, DefaultReplyTimeout)
}
// SendCompleteAs is like SendComplete but for a specific user/chat.
func (e *Env) SendCompleteAs(userID, chatID, content string, idlePeriod, timeout time.Duration) []*bbplatform.SentMessage {
e.T.Helper()
before := e.Platform.MessageCount()
e.Platform.InjectMessage(userID, chatID, content)
msgs := e.Platform.WaitForTurnComplete(before, idlePeriod, timeout)
if msgs == nil {
e.T.Fatalf(
"blackbox timeout (%v): no complete turn for %q from %s/%s\nagent=%s\nall messages so far:\n%s",
timeout, content, userID, chatID, e.agentType,
e.Platform.AllText(),
)
}
return msgs
}
// LastText returns the text of the last message in a turn's message slice.
// Convenient for context retention assertions.
func LastText(msgs []*bbplatform.SentMessage) string {
if len(msgs) == 0 {
return ""
}
return msgs[len(msgs)-1].Text()
}
// AnyText returns all texts joined, for searching across multi-message turns.
func AnyText(msgs []*bbplatform.SentMessage) string {
parts := make([]string, len(msgs))
for i, m := range msgs {
parts[i] = m.Text()
}
return strings.Join(parts, " ")
}
// SendNoWait injects a message but does not wait for a reply.
// Use for /stop scenarios where you send a command mid-processing.
func (e *Env) SendNoWait(content string) {
e.Platform.InjectMessage(DefaultUser, DefaultChat, content)
}
// SendNoWaitAs injects from a specific user/chat without waiting.
func (e *Env) SendNoWaitAs(userID, chatID, content string) {
e.Platform.InjectMessage(userID, chatID, content)
}
// ExpectReply waits for the next reply (after the current message count).
// Fails the test if nothing arrives before timeout.
func (e *Env) ExpectReply(timeout time.Duration) *bbplatform.SentMessage {
e.T.Helper()
before := e.Platform.MessageCount()
reply := e.Platform.WaitForReply(before, timeout)
if reply == nil {
e.T.Fatalf(
"blackbox timeout (%v): expected a reply\nagent=%s\nall messages so far:\n%s",
timeout, e.agentType, e.Platform.AllText(),
)
}
return reply
}
// WaitForMessageContaining waits for any message (from startIdx) containing
// substr (case-insensitive). Fails on timeout.
func (e *Env) WaitForMessageContaining(startIdx int, substr string, timeout time.Duration) *bbplatform.SentMessage {
e.T.Helper()
msg := e.Platform.WaitForMessageContaining(startIdx, substr, timeout)
if msg == nil {
e.T.Fatalf(
"blackbox timeout (%v): no message containing %q\nagent=%s\nall messages:\n%s",
timeout, substr, e.agentType, e.Platform.AllText(),
)
}
return msg
}
// SessionKey returns the session key for the default user/chat, matching the
// format cc-connect uses internally: "<platform>:<chat>:<user>".
func (e *Env) SessionKey() string {
return fmt.Sprintf("%s:%s:%s", e.Platform.Name(), DefaultChat, DefaultUser)
}
// SessionKeyFor returns the session key for a specific user/chat.
func (e *Env) SessionKeyFor(userID, chatID string) string {
return fmt.Sprintf("%s:%s:%s", e.Platform.Name(), chatID, userID)
}
// ── Skip guards ───────────────────────────────────────────────────────────────
// requireAgent skips (never fails) if the agent binary or API credentials are
// unavailable. The distinction between skip and fail is intentional: missing
// credentials mean the test simply cannot run in this environment; a logical
// assertion failure inside the test is a real failure.
func requireAgent(t *testing.T, agentType string) {
t.Helper()
// agentBinName returns the primary CLI binary name for the agent type.
// The cursor type uses "agent" (from @anthropic-ai/cursor-agent), not
// the Cursor IDE "cursor" binary.
bin := agentBinName(agentType)
if bin == "" {
t.Skipf("blackbox skip: unknown agent type %q", agentType)
}
if _, err := exec.LookPath(bin); err != nil {
// For cursor, also accept if the "cursor" IDE binary is present
// (some setups install it under that name instead).
if agentType == "cursor" {
if _, err2 := exec.LookPath("cursor"); err2 != nil {
t.Skipf("blackbox skip: cursor agent binary %q not in PATH (also checked 'cursor')", bin)
}
} else {
t.Skipf("blackbox skip: %s binary %q not in PATH", agentType, bin)
}
}
switch agentType {
case "claudecode":
if os.Getenv("ANTHROPIC_API_KEY") == "" && !hasProviderEnv("claudecode") {
t.Skipf("blackbox skip: ANTHROPIC_API_KEY not set")
}
case "codex":
if os.Getenv("OPENAI_API_KEY") == "" && !hasProviderEnv("codex") {
t.Skipf("blackbox skip: OPENAI_API_KEY not set")
}
case "gemini":
if os.Getenv("GEMINI_API_KEY") == "" && os.Getenv("GOOGLE_API_KEY") == "" && !hasProviderEnv("gemini") {
t.Skipf("blackbox skip: GEMINI_API_KEY or GOOGLE_API_KEY not set")
}
case "cursor":
// Cursor can run without an explicit API key if the user is already
// authenticated via `cursor login`. Only skip when the binary is absent
// (handled above). Let it fail naturally with an auth error if needed.
case "opencode":
if os.Getenv("ANTHROPIC_API_KEY") == "" && !hasProviderEnv("opencode") {
t.Skipf("blackbox skip: ANTHROPIC_API_KEY not set for opencode")
}
}
}
// hasProviderEnv checks if a CC_BLACKBOX_<AGENT>_API_KEY override is set.
// Allows CI to inject credentials specifically for blackbox tests without
// polluting the main API key env vars.
func hasProviderEnv(agentType string) bool {
key := "CC_BLACKBOX_" + strings.ToUpper(agentType) + "_API_KEY"
return os.Getenv(key) != ""
}
// applyProviderFromEnv injects API credentials into agent opts.
// Preference order:
// 1. CC_BLACKBOX_<AGENT>_BASE_URL + CC_BLACKBOX_<AGENT>_API_KEY (test-specific)
// 2. Standard env vars (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)
func applyProviderFromEnv(t *testing.T, agentType string, opts map[string]any) {
t.Helper()
prefix := "CC_BLACKBOX_" + strings.ToUpper(agentType) + "_"
apiKey := os.Getenv(prefix + "API_KEY")
baseURL := os.Getenv(prefix + "BASE_URL")
model := os.Getenv(prefix + "MODEL")
if apiKey == "" {
switch agentType {
case "claudecode":
apiKey = os.Getenv("ANTHROPIC_API_KEY")
baseURL = os.Getenv("ANTHROPIC_BASE_URL")
case "codex":
apiKey = os.Getenv("OPENAI_API_KEY")
baseURL = os.Getenv("OPENAI_BASE_URL")
case "gemini":
apiKey = firstNonEmpty(os.Getenv("GEMINI_API_KEY"), os.Getenv("GOOGLE_API_KEY"))
case "opencode":
apiKey = os.Getenv("ANTHROPIC_API_KEY")
baseURL = os.Getenv("ANTHROPIC_BASE_URL")
case "cursor":
apiKey = firstNonEmpty(os.Getenv("CURSOR_API_KEY"), os.Getenv("ANTHROPIC_API_KEY"))
}
}
if apiKey != "" {
opts["api_key"] = apiKey
}
if baseURL != "" {
opts["base_url"] = baseURL
}
if model != "" {
opts["model"] = model
}
}
// wireProviders calls SetProviders on the agent when CC_BLACKBOX_<AGENT>_API_KEY
// is set, giving agents (e.g. claudecode) their credentials via the provider
// interface rather than relying solely on ANTHROPIC_API_KEY env vars.
//
// Supported env vars (prefix = CC_BLACKBOX_<AGENTTYPE>_):
//
// <prefix>API_KEY — required (except cursor which can use local login)
// <prefix>BASE_URL — provider base URL / proxy endpoint
// <prefix>MODEL — model override
// <prefix>WIRE_API — codex wire API format, e.g. "chat" or "responses"
//
// Agent-specific base URL injection:
// - claudecode: base_url → stored in ProviderConfig.BaseURL (provider sets ANTHROPIC_BASE_URL)
// - opencode: base_url → injected as ANTHROPIC_BASE_URL env var via ProviderConfig.Env
// - cursor: no base_url injection (uses CURSOR_API_KEY or local auth)
func wireProviders(t *testing.T, agentType string, agent core.Agent) {
t.Helper()
ps, ok := agent.(core.ProviderSwitcher)
if !ok {
return
}
prefix := "CC_BLACKBOX_" + strings.ToUpper(agentType) + "_"
apiKey := os.Getenv(prefix + "API_KEY")
baseURL := os.Getenv(prefix + "BASE_URL")
model := os.Getenv(prefix + "MODEL")
wireAPI := os.Getenv(prefix + "WIRE_API")
// Skip if no explicit API key override (opencode and cursor can fall back
// to their native auth from env or local login).
if apiKey == "" && agentType != "cursor" && agentType != "opencode" {
return
}
// For cursor/opencode with no API key, still wire a provider if we have model.
if apiKey == "" && model == "" {
return
}
provider := core.ProviderConfig{
Name: "blackbox-test",
APIKey: apiKey,
BaseURL: baseURL,
Model: model,
CodexWireAPI: wireAPI,
}
// opencode's providerEnvLocked only injects APIKey (as ANTHROPIC_API_KEY)
// and the Env map. To use a custom proxy (e.g. minimax), inject BaseURL as
// ANTHROPIC_BASE_URL via the Env map so opencode picks it up.
if agentType == "opencode" && baseURL != "" {
if provider.Env == nil {
provider.Env = make(map[string]string)
}
provider.Env["ANTHROPIC_BASE_URL"] = baseURL
}
ps.SetProviders([]core.ProviderConfig{provider})
ps.SetActiveProvider("blackbox-test")
t.Logf("blackbox: wired provider base_url=%s model=%s wire_api=%s", baseURL, model, wireAPI)
}
func agentBinName(agentType string) string {
switch agentType {
case "claudecode":
return "claude"
case "codex":
return "codex"
case "gemini":
return "gemini"
case "cursor":
// Cursor Agent CLI installed via npm: @anthropic-ai/cursor-agent.
// The binary is named "agent" by default; some installations also link
// it as "cursor". We try "agent" first.
return "agent"
case "opencode":
return "opencode"
case "qoder":
return "qoder"
case "kimi":
return "kimi"
default:
return agentType
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
+175
View File
@@ -0,0 +1,175 @@
//go:build blackbox
// Package p0 contains P0 blackbox tests — the hard release gate.
//
// These tests use real agents (Claude Code, Codex, etc.) and a MockPlatform.
// They are skipped when agent binaries or API credentials are unavailable,
// but FAIL on any logical assertion failure.
//
// Run all P0 tests:
//
// go test -tags blackbox ./tests/blackbox/p0/... -timeout 1800s -v
//
// Run against a specific agent:
//
// CC_BLACKBOX_CLAUDECODE_API_KEY=xxx go test -tags blackbox \
// ./tests/blackbox/p0/... -run ClaudeCode -timeout 1800s -v
package p0
import (
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
)
// ── P0-1: 基础消息收发 ────────────────────────────────────────────────────────
// TestP0_1_BasicMessageFlow_ClaudeCode verifies the core message loop:
// user sends a message → cc-connect routes it to Claude Code → user receives
// a non-empty reply.
//
// This is the most fundamental P0 test. If this fails, nothing else matters.
func TestP0_1_BasicMessageFlow_ClaudeCode(t *testing.T) {
t.Parallel()
testBasicMessageFlow(t, "claudecode")
}
func TestP0_1_BasicMessageFlow_Codex(t *testing.T) {
t.Parallel()
testBasicMessageFlow(t, "codex")
}
// TestP0_1_BasicMessageFlow_Cursor tests the cursor agent (agent binary,
// @anthropic-ai/cursor-agent). Set CC_BLACKBOX_CURSOR_API_KEY + optionally
// CC_BLACKBOX_CURSOR_MODEL (e.g. "claude-sonnet-4-5") to run.
// Cursor supports composer-2-fast via the model name; use a cheap model
// for CI (e.g. "claude-haiku-3-5-20241022").
func TestP0_1_BasicMessageFlow_Cursor(t *testing.T) {
t.Parallel()
testBasicMessageFlow(t, "cursor")
}
// TestP0_1_BasicMessageFlow_OpenCode tests the opencode agent (opencode binary).
// Set CC_BLACKBOX_OPENCODE_API_KEY and CC_BLACKBOX_OPENCODE_BASE_URL to use a
// custom proxy (e.g. minimax, dragoncode) or set ANTHROPIC_API_KEY for direct
// Anthropic API access.
func TestP0_1_BasicMessageFlow_OpenCode(t *testing.T) {
t.Parallel()
testBasicMessageFlow(t, "opencode")
}
func testBasicMessageFlow(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
reply := env.Send("say hi briefly")
if reply == nil {
t.Fatal("no reply received")
}
if strings.TrimSpace(reply.Text()) == "" {
t.Fatalf("received empty reply; all messages:\n%s", env.Platform.AllText())
}
t.Logf("P0-1 OK: received reply (%d chars): %q", len(reply.Text()), truncate(reply.Text(), 200))
}
// ── P0-2: /new 创建新会话 ─────────────────────────────────────────────────────
// TestP0_2_NewSession_ClaudeCode verifies /new creates a new session and
// cc-connect sends a confirmation message.
func TestP0_2_NewSession_ClaudeCode(t *testing.T) {
t.Parallel()
testNewSession(t, "claudecode")
}
func testNewSession(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
// First, establish a session by sending a real message.
env.Send("say hello briefly")
// Now create a new session.
before := env.Platform.MessageCount()
env.SendNoWait("/new")
reply := env.Platform.WaitForReply(before, 30*time.Second)
if reply == nil {
t.Fatal("/new: no confirmation message received within 30s")
}
t.Logf("P0-2 OK: /new replied: %q", truncate(reply.Text(), 200))
}
// ── P0-3: /list 显示会话列表 ──────────────────────────────────────────────────
// TestP0_3_ListSessions_ClaudeCode verifies /list returns a message containing
// session information after at least one session has been created.
func TestP0_3_ListSessions_ClaudeCode(t *testing.T) {
t.Parallel()
testListSessions(t, "claudecode")
}
func testListSessions(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
// Establish a session.
env.Send("say hello briefly")
// /list should return session information.
reply := env.Send("/list")
text := strings.ToLower(reply.Text())
// The response should reference sessions in some form.
hasSessionInfo := strings.Contains(text, "session") ||
strings.Contains(text, "会话") ||
strings.Contains(text, "#1") ||
strings.Contains(text, "no sessions") ||
strings.Contains(text, "没有会话")
if !hasSessionInfo {
t.Errorf("/list response doesn't look like a session list\ngot: %q\nall messages:\n%s",
reply.Text(), env.Platform.AllText())
}
t.Logf("P0-3 OK: /list replied: %q", truncate(reply.Text(), 300))
}
// ── P0-5: /stop 停止当前任务 ──────────────────────────────────────────────────
// TestP0_5_StopCurrentTask_ClaudeCode verifies /stop causes cc-connect to
// reply within a reasonable time even while a task is running.
//
// Strategy: we don't send a genuinely long-running task (too unreliable);
// instead we verify /stop itself produces a reply when no task is active,
// which still confirms the command is handled.
func TestP0_5_StopCurrentTask_ClaudeCode(t *testing.T) {
t.Parallel()
testStop(t, "claudecode")
}
func testStop(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
// Establish a session first so /stop has context.
env.Send("say hello briefly")
// Issue /stop and expect a timely response.
reply := env.SendWithTimeout("/stop", 30*time.Second)
if strings.TrimSpace(reply.Text()) == "" {
t.Fatalf("/stop produced empty reply; all messages:\n%s", env.Platform.AllText())
}
t.Logf("P0-5 OK: /stop replied: %q", truncate(reply.Text(), 200))
}
// ── helpers ───────────────────────────────────────────────────────────────────
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "…"
}
+120
View File
@@ -0,0 +1,120 @@
//go:build blackbox
package p0
import (
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
)
// ── P0-11: 上下文保持(最核心场景)──────────────────────────────────────────
//
// This is arguably the most important test in the suite. It validates that:
// 1. cc-connect maintains a persistent session across multiple messages
// 2. the real agent (Claude Code) retains conversation context
// 3. the session key routing is correct end-to-end
//
// If context retention breaks, users cannot have multi-turn conversations —
// the entire product stops working for its core use case.
// TestP0_11_ContextRetention_ClaudeCode sends a name in turn 1 and verifies
// the agent recalls it in turn 2. This is only possible with a real persistent
// session — a fake agent would need the name hardcoded.
func TestP0_11_ContextRetention_ClaudeCode(t *testing.T) {
t.Parallel()
testContextRetention(t, "claudecode")
}
func TestP0_11_ContextRetention_Codex(t *testing.T) {
t.Parallel()
testContextRetention(t, "codex")
}
func TestP0_11_ContextRetention_Cursor(t *testing.T) {
t.Parallel()
testContextRetention(t, "cursor")
}
func TestP0_11_ContextRetention_OpenCode(t *testing.T) {
t.Parallel()
testContextRetention(t, "opencode")
}
func testContextRetention(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
// Turn 1: plant a unique fact. Use SendComplete so we wait for the full
// agent turn before sending turn 2 — otherwise the second message gets
// queued while the agent is still processing and loses context.
const uniqueName = "ContextTestMarker42"
turn1 := env.SendComplete("My name is " + uniqueName + ". Just acknowledge it briefly.")
turn1Text := helper.AnyText(turn1)
if strings.TrimSpace(turn1Text) == "" {
t.Fatalf("turn 1: empty reply; all messages:\n%s", env.Platform.AllText())
}
t.Logf("turn 1 (%d msgs): %q", len(turn1), truncate(turn1Text, 200))
// Turn 2: ask the agent to recall the fact.
turn2 := env.SendComplete("What is my name? Reply with just the name.")
turn2Text := helper.AnyText(turn2)
if strings.TrimSpace(turn2Text) == "" {
t.Fatalf("turn 2: empty reply; all messages:\n%s", env.Platform.AllText())
}
t.Logf("turn 2 (%d msgs): %q", len(turn2), truncate(turn2Text, 200))
// The agent must recall the name planted in turn 1.
if !strings.Contains(strings.ToLower(turn2Text), strings.ToLower(uniqueName)) {
t.Errorf(
"context retention FAILED: turn 2 does not contain %q\n"+
"turn 1: %q\n"+
"turn 2: %q\n"+
"all messages:\n%s",
uniqueName, turn1Text, turn2Text, env.Platform.AllText(),
)
} else {
t.Logf("P0-11 OK: agent recalled %q in turn 2", uniqueName)
}
}
// TestP0_11_ContextIsolation_ClaudeCode verifies that two different users
// (different session keys) have completely independent sessions.
// User A's context must NOT appear in User B's session.
func TestP0_11_ContextIsolation_ClaudeCode(t *testing.T) {
t.Parallel()
testContextIsolation(t, "claudecode")
}
func testContextIsolation(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
const nameA = "AliceIsolationTest"
const nameB = "BobIsolationTest"
// User A plants their name (wait for full turn).
msgsA1 := env.SendCompleteAs("userA", "chat1", "My name is "+nameA+". Just say ok.", 5*time.Second, helper.DefaultReplyTimeout)
t.Logf("userA turn 1: %q", truncate(helper.AnyText(msgsA1), 150))
// User B (different session) plants their name (wait for full turn).
msgsB1 := env.SendCompleteAs("userB", "chat1", "My name is "+nameB+". Just say ok.", 5*time.Second, helper.DefaultReplyTimeout)
t.Logf("userB turn 1: %q", truncate(helper.AnyText(msgsB1), 150))
// User A asks — should see only their own name.
msgsA2 := env.SendCompleteAs("userA", "chat1", "What is my name?", 5*time.Second, helper.DefaultReplyTimeout)
textA2 := helper.AnyText(msgsA2)
t.Logf("userA turn 2: %q", truncate(textA2, 150))
if !strings.Contains(strings.ToLower(textA2), strings.ToLower(nameA)) {
t.Errorf("userA context lost: expected %q in reply, got: %q", nameA, textA2)
}
if strings.Contains(strings.ToLower(textA2), strings.ToLower(nameB)) {
t.Errorf("session leak: userA reply contains userB's name %q: %q", nameB, textA2)
}
t.Logf("P0-11 isolation OK")
}
+79
View File
@@ -0,0 +1,79 @@
//go:build blackbox
package p0
import (
"strings"
"testing"
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
)
// ── P0-14: 工具调用 ───────────────────────────────────────────────────────────
// TestP0_14_ToolInvocation_ClaudeCode verifies that cc-connect routes a task
// requiring tool use to the agent and the agent successfully executes it.
// A real agent calling real tools is the only way to verify this end-to-end.
func TestP0_14_ToolInvocation_ClaudeCode(t *testing.T) {
t.Parallel()
testToolInvocation(t, "claudecode")
}
func testToolInvocation(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
// Ask something that requires a Bash/filesystem tool call.
msgs := env.SendComplete("List the files in the current directory using a shell command. Just show me the output.")
combined := helper.AnyText(msgs)
if strings.TrimSpace(combined) == "" {
t.Fatalf("empty reply; all messages:\n%s", env.Platform.AllText())
}
// The agent should have invoked a tool — expect a tool-call indicator or
// file listing output. We check for the Tool indicator message or any
// file/directory names in the response.
hasToolEvidence := strings.Contains(combined, "🔧") ||
strings.Contains(combined, "Tool") ||
strings.Contains(combined, "Bash") ||
strings.Contains(combined, ".go") ||
strings.Contains(combined, "go.mod") ||
strings.Contains(combined, "main") ||
strings.Contains(combined, "ls") ||
strings.Contains(strings.ToLower(combined), "file") ||
strings.Contains(strings.ToLower(combined), "directory") ||
strings.Contains(strings.ToLower(combined), "目录") ||
strings.Contains(strings.ToLower(combined), "文件")
if !hasToolEvidence {
t.Errorf("P0-14: no evidence of tool invocation in response\nall messages:\n%s", env.Platform.AllText())
} else {
t.Logf("P0-14 OK: tool invocation detected (%d msgs, %d chars)", len(msgs), len(combined))
}
}
// ── P0-15: 长消息流式输出 ─────────────────────────────────────────────────────
// TestP0_15_LongResponse_ClaudeCode verifies that cc-connect correctly handles
// a long agent response without truncation or corruption. The agent must
// produce a substantive response (≥ 200 chars total).
func TestP0_15_LongResponse_ClaudeCode(t *testing.T) {
t.Parallel()
testLongResponse(t, "claudecode")
}
func testLongResponse(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
msgs := env.SendComplete("Write a short paragraph (about 150 words) explaining what software testing is. Write in plain text.")
combined := helper.AnyText(msgs)
if len(strings.TrimSpace(combined)) < 200 {
t.Errorf("P0-15: response too short (%d chars); expected ≥ 200\nall messages:\n%s",
len(combined), env.Platform.AllText())
} else {
t.Logf("P0-15 OK: long response received (%d msgs, %d chars)", len(msgs), len(combined))
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build blackbox
package p1
import (
_ "github.com/chenhg5/cc-connect/agent/claudecode"
_ "github.com/chenhg5/cc-connect/agent/codex"
_ "github.com/chenhg5/cc-connect/agent/cursor"
_ "github.com/chenhg5/cc-connect/agent/gemini"
_ "github.com/chenhg5/cc-connect/agent/opencode"
_ "github.com/chenhg5/cc-connect/agent/qoder"
)
+209
View File
@@ -0,0 +1,209 @@
//go:build blackbox
// Package p1 contains P1 blackbox tests.
//
// Session commands (/help, /current, /name, /switch, /delete, /status, /version)
// are dispatched by cc-connect's engine directly, so they respond within
// seconds regardless of agent speed.
//
// Run:
//
// CC_BLACKBOX_CLAUDECODE_API_KEY=xxx \
// CC_BLACKBOX_CLAUDECODE_BASE_URL=https://... \
// go test -tags blackbox ./tests/blackbox/p1/... -timeout 1200s -v
package p1
import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
bbplatform "github.com/chenhg5/cc-connect/tests/blackbox/platform"
)
const cmdTimeout = 30 * time.Second // engine-handled commands are near-instant
// ── P1-1: /help ──────────────────────────────────────────────────────────────
func TestP1_1_Help_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
reply := env.SendWithTimeout("/help", cmdTimeout)
assertContainsAny(t, "P1-1 /help", reply.Text(),
"/new", "/list", "/stop", "/help", "Available", "命令", "Command")
t.Logf("P1-1 OK: %q", truncate(reply.Text(), 200))
}
// ── P1-5: /current ───────────────────────────────────────────────────────────
func TestP1_5_Current_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
env.Send("say hi briefly")
reply := env.SendWithTimeout("/current", cmdTimeout)
assertContainsAny(t, "P1-5 /current", strings.ToLower(reply.Text()),
"session", "会话", "#1", "s1")
t.Logf("P1-5 OK: %q", truncate(reply.Text(), 200))
}
// ── P1-7: /name ──────────────────────────────────────────────────────────────
func TestP1_7_Name_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
env.Send("say hi briefly")
const newName = "blackbox-rename-test"
env.SendWithTimeout("/name "+newName, cmdTimeout)
listReply := env.SendWithTimeout("/list", cmdTimeout)
if !strings.Contains(listReply.Text(), newName) {
t.Errorf("P1-7 /name: %q not in /list\n%q", newName, listReply.Text())
}
t.Logf("P1-7 OK: /list shows renamed session %q", newName)
}
// ── P1-4: /switch ────────────────────────────────────────────────────────────
func TestP1_4_Switch_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
env.Send("say hi briefly")
env.SendWithTimeout("/new", cmdTimeout)
listReply := env.SendWithTimeout("/list", cmdTimeout)
if strings.Count(listReply.Text(), "msgs") < 2 {
t.Skipf("could not establish 2 sessions; /list: %q", listReply.Text())
}
switchReply := env.SendWithTimeout("/switch 1", cmdTimeout)
t.Logf("/switch 1: %q", truncate(switchReply.Text(), 150))
currentReply := env.SendWithTimeout("/current", cmdTimeout)
t.Logf("P1-4 OK: /current after switch: %q", truncate(currentReply.Text(), 150))
}
// ── P1-6: /delete ────────────────────────────────────────────────────────────
func TestP1_6_Delete_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
env.Send("say hi briefly")
env.SendWithTimeout("/new", cmdTimeout)
env.Send("say hello briefly")
env.SendWithTimeout("/new", cmdTimeout)
env.SendWithTimeout("/delete 2", cmdTimeout)
listReply := env.SendWithTimeout("/list", cmdTimeout)
remaining := strings.Count(listReply.Text(), "msgs")
if remaining != 2 {
t.Errorf("P1-6 /delete: expected 2 sessions, got %d\n/list: %q", remaining, listReply.Text())
}
t.Logf("P1-6 OK: 2 sessions remain after deleting #2")
}
// ── P1-9: /status ────────────────────────────────────────────────────────────
func TestP1_9_Status_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
reply := env.SendWithTimeout("/status", cmdTimeout)
assertContainsAny(t, "P1-9 /status", strings.ToLower(reply.Text()),
"agent", "project", "session", "claudecode", "状态")
t.Logf("P1-9 OK: %q", truncate(reply.Text(), 200))
}
// ── P1-10: /version ──────────────────────────────────────────────────────────
func TestP1_10_Version_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
reply := env.SendWithTimeout("/version", cmdTimeout)
// VersionInfo is set by main at startup; in tests it's empty string "".
// The command must still produce a reply (even if content is empty in tests),
// confirming the command is dispatched without error.
if reply == nil {
t.Fatal("P1-10 /version: no reply received")
}
// In production, VersionInfo contains a version string like "v1.3.x".
// In test environment it's empty — this is expected.
t.Logf("P1-10 OK: /version replied (content: %q)", truncate(reply.Text(), 100))
}
// ── P1-14: 消息排队 ───────────────────────────────────────────────────────────
func TestP1_14_MessageQueuing_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
// Fire 3 messages rapidly while the agent may still be starting.
env.Platform.InjectMessage(helper.DefaultUser, helper.DefaultChat, "reply with only the word: FIRST")
env.Platform.InjectMessage(helper.DefaultUser, helper.DefaultChat, "reply with only the word: SECOND")
env.Platform.InjectMessage(helper.DefaultUser, helper.DefaultChat, "reply with only the word: THIRD")
// Wait until all 3 keywords appear in the combined output.
// We can't predict exact message count due to thinking/queue notifications.
deadline := time.Now().Add(3 * helper.DefaultReplyTimeout)
for {
combined := strings.ToUpper(env.Platform.AllText())
allFound := strings.Contains(combined, "FIRST") &&
strings.Contains(combined, "SECOND") &&
strings.Contains(combined, "THIRD")
if allFound {
break
}
if time.Now().After(deadline) {
t.Fatalf("P1-14: timeout waiting for FIRST/SECOND/THIRD\nall messages:\n%s",
env.Platform.AllText())
}
time.Sleep(500 * time.Millisecond)
}
t.Logf("P1-14 OK: all 3 queued messages processed\nall messages:\n%s", env.Platform.AllText())
}
// ── shared helpers ────────────────────────────────────────────────────────────
func assertContainsAny(t *testing.T, label, text string, candidates ...string) {
t.Helper()
lower := strings.ToLower(text)
for _, c := range candidates {
if strings.Contains(lower, strings.ToLower(c)) {
return
}
}
t.Errorf("%s: none of %v found\ngot: %q", label, candidates, text)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "…"
}
// restartedEngine stops env.Engine and returns a new Engine + MockPlatform
// that share the same session store — simulating a service restart.
func restartedEngine(t *testing.T, agentType, dataDir string) (*core.Engine, *bbplatform.MockPlatform) {
t.Helper()
opts := map[string]any{"work_dir": t.TempDir()}
agent, err := core.CreateAgent(agentType, opts)
if err != nil {
t.Skipf("restart skip: cannot create %s agent: %v", agentType, err)
}
mp := bbplatform.New(agentType + "-restarted")
sessPath := filepath.Join(dataDir, "sessions.json")
engine := core.NewEngine("blackbox-restarted", agent, []core.Platform{mp}, sessPath, core.LangEnglish)
if err := engine.Start(); err != nil {
t.Fatalf("restarted engine Start failed: %v", err)
}
t.Cleanup(func() { engine.Stop(); agent.Stop() })
return engine, mp
}
@@ -0,0 +1,276 @@
//go:build blackbox
package p1
// Session visibility regression suite — P1-30 to P1-40.
//
// These tests reproduce bugs from v1.3.1:
// - /list lost sessions after /new
// - /new xxx names didn't persist
// - /switch caused sessions to disappear
//
// Per checklist: ALL P1-30..40 tests must run on BOTH claudecode AND codex.
import (
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
)
// ── P1-30: 历史会话全部可见 ───────────────────────────────────────────────────
func TestP1_30_HistorySessionsVisible_ClaudeCode(t *testing.T) {
t.Parallel()
testHistorySessionsVisible(t, "claudecode")
}
func TestP1_30_HistorySessionsVisible_Codex(t *testing.T) {
t.Parallel()
testHistorySessionsVisible(t, "codex")
}
func testHistorySessionsVisible(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
env.Send("say ALPHA briefly")
env.SendWithTimeout("/new", cmdTimeout)
env.Send("say BETA briefly")
env.SendWithTimeout("/new", cmdTimeout)
env.Send("say GAMMA briefly")
listReply := env.SendWithTimeout("/list", cmdTimeout)
count := strings.Count(listReply.Text(), "msgs")
if count < 3 {
t.Errorf("P1-30: expected ≥3 sessions, got %d\n/list: %q", count, listReply.Text())
}
t.Logf("P1-30 OK: %d sessions visible in /list", count)
}
// ── P1-31: /new 后 /list 完整 ─────────────────────────────────────────────────
func TestP1_31_NewThenListComplete_ClaudeCode(t *testing.T) {
t.Parallel()
testNewThenListComplete(t, "claudecode")
}
func TestP1_31_NewThenListComplete_Codex(t *testing.T) {
t.Parallel()
testNewThenListComplete(t, "codex")
}
func testNewThenListComplete(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
// Session 1: wait for full agent turn before creating session 2.
env.SendComplete("say hi briefly")
env.SendWithTimeout("/new", cmdTimeout)
env.SendComplete("say hello briefly")
listReply := env.SendWithTimeout("/list", cmdTimeout)
count := strings.Count(listReply.Text(), "msgs")
if count < 2 {
t.Errorf("P1-31: expected ≥2 sessions after /new, got %d\n/list: %q", count, listReply.Text())
}
t.Logf("P1-31 OK: %d sessions after /new + message", count)
}
// ── P1-32: /new xxx 命名生效 ─────────────────────────────────────────────────
func TestP1_32_NewWithNaming_ClaudeCode(t *testing.T) {
t.Parallel()
testNewWithNaming(t, "claudecode")
}
func TestP1_32_NewWithNaming_Codex(t *testing.T) {
t.Parallel()
testNewWithNaming(t, "codex")
}
func testNewWithNaming(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
const sessionName = "regression-name-test"
env.SendWithTimeout("/new "+sessionName, cmdTimeout)
env.SendComplete("say ok briefly")
listReply := env.SendWithTimeout("/list", cmdTimeout)
if !strings.Contains(listReply.Text(), sessionName) {
t.Errorf("P1-32: /new %q name missing in /list\n/list: %q", sessionName, listReply.Text())
}
t.Logf("P1-32 OK: session name %q appears in /list", sessionName)
}
// ── P1-33: /name 重命名 ──────────────────────────────────────────────────────
func TestP1_33_NameRename_ClaudeCode(t *testing.T) {
t.Parallel()
testNameRename(t, "claudecode")
}
func TestP1_33_NameRename_Codex(t *testing.T) {
t.Parallel()
testNameRename(t, "codex")
}
func testNameRename(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
env.SendComplete("say hi briefly")
const newName = "renamed-42"
env.SendWithTimeout("/name "+newName, cmdTimeout)
listReply := env.SendWithTimeout("/list", cmdTimeout)
if !strings.Contains(listReply.Text(), newName) {
t.Errorf("P1-33: /name %q not in /list\n/list: %q", newName, listReply.Text())
}
t.Logf("P1-33 OK: renamed session %q in /list", newName)
}
// ── P1-34: /switch 后 /list 完整 ─────────────────────────────────────────────
func TestP1_34_SwitchPreservesAllSessions_ClaudeCode(t *testing.T) {
t.Parallel()
testSwitchPreservesAllSessions(t, "claudecode")
}
func TestP1_34_SwitchPreservesAllSessions_Codex(t *testing.T) {
t.Parallel()
testSwitchPreservesAllSessions(t, "codex")
}
func testSwitchPreservesAllSessions(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
env.SendComplete("say hi briefly")
env.SendWithTimeout("/new", cmdTimeout)
env.SendComplete("say hello briefly")
env.SendWithTimeout("/switch 1", cmdTimeout)
listReply := env.SendWithTimeout("/list", cmdTimeout)
count := strings.Count(listReply.Text(), "msgs")
if count < 2 {
t.Errorf("P1-34: /list after /switch shows %d session(s), expected ≥2\n/list: %q",
count, listReply.Text())
}
t.Logf("P1-34 OK: %d sessions preserved after /switch", count)
}
// ── P1-35: /delete 只删目标 ──────────────────────────────────────────────────
func TestP1_35_DeleteOnlyRemovesTarget_ClaudeCode(t *testing.T) {
t.Parallel()
testDeleteOnlyRemovesTarget(t, "claudecode")
}
func TestP1_35_DeleteOnlyRemovesTarget_Codex(t *testing.T) {
t.Parallel()
testDeleteOnlyRemovesTarget(t, "codex")
}
func testDeleteOnlyRemovesTarget(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
env.SendComplete("say one briefly")
env.SendWithTimeout("/new", cmdTimeout)
env.SendComplete("say two briefly")
env.SendWithTimeout("/new", cmdTimeout)
env.SendComplete("say three briefly")
pre := env.SendWithTimeout("/list", cmdTimeout)
if strings.Count(pre.Text(), "msgs") < 3 {
t.Skipf("P1-35: could not establish 3 sessions; /list: %q", pre.Text())
}
env.SendWithTimeout("/delete 2", cmdTimeout)
post := env.SendWithTimeout("/list", cmdTimeout)
remaining := strings.Count(post.Text(), "msgs")
if remaining != 2 {
t.Errorf("P1-35: expected 2 sessions after /delete 2, got %d\n/list: %q",
remaining, post.Text())
}
t.Logf("P1-35 OK: 2 sessions remain after deleting #2")
}
// ── P1-37: Agent 回复后 /list 不减少 ─────────────────────────────────────────
func TestP1_37_ListAfterReplyStable_ClaudeCode(t *testing.T) {
t.Parallel()
testListAfterReplyStable(t, "claudecode")
}
func TestP1_37_ListAfterReplyStable_Codex(t *testing.T) {
t.Parallel()
testListAfterReplyStable(t, "codex")
}
func testListAfterReplyStable(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
env.SendComplete("say hi briefly")
env.SendWithTimeout("/new", cmdTimeout)
env.SendComplete("say hello briefly")
pre := env.SendWithTimeout("/list", cmdTimeout)
preCnt := strings.Count(pre.Text(), "msgs")
time.Sleep(500 * time.Millisecond)
post := env.SendWithTimeout("/list", cmdTimeout)
postCnt := strings.Count(post.Text(), "msgs")
if postCnt < preCnt {
t.Errorf("P1-37: session count dropped %d → %d after agent reply\npre: %q\npost: %q",
preCnt, postCnt, pre.Text(), post.Text())
}
t.Logf("P1-37 OK: session count stable (%d → %d)", preCnt, postCnt)
}
// ── P1-39: 重启后名称保留 ─────────────────────────────────────────────────────
func TestP1_39_RestartPreservesNames_ClaudeCode(t *testing.T) {
// Not parallel — involves engine stop/restart.
testRestartPreservesNames(t, "claudecode")
}
func testRestartPreservesNames(t *testing.T, agentType string) {
t.Helper()
env := helper.NewEnv(t, agentType)
const sessionName = "persisted-name-99"
env.SendWithTimeout("/new "+sessionName, cmdTimeout)
env.SendComplete("say ok briefly")
pre := env.SendWithTimeout("/list", cmdTimeout)
if !strings.Contains(pre.Text(), sessionName) {
t.Skipf("P1-39: name %q not established pre-restart (%q); skipping", sessionName, pre.Text())
}
// Simulate restart: stop engine, start fresh engine with same session store.
env.Engine.Stop()
_, newMP := restartedEngine(t, agentType, env.DataDir)
before := newMP.MessageCount()
newMP.InjectMessage(helper.DefaultUser, helper.DefaultChat, "/list")
listReply := newMP.WaitForReply(before, cmdTimeout)
if listReply == nil {
t.Fatal("P1-39: no /list response after restart")
}
if !strings.Contains(listReply.Text(), sessionName) {
t.Errorf("P1-39: name %q lost after restart\n/list: %q", sessionName, listReply.Text())
}
t.Logf("P1-39 OK: name %q preserved after restart", sessionName)
}
+12
View File
@@ -0,0 +1,12 @@
//go:build blackbox
package p2
import (
_ "github.com/chenhg5/cc-connect/agent/claudecode"
_ "github.com/chenhg5/cc-connect/agent/codex"
_ "github.com/chenhg5/cc-connect/agent/cursor"
_ "github.com/chenhg5/cc-connect/agent/gemini"
_ "github.com/chenhg5/cc-connect/agent/opencode"
_ "github.com/chenhg5/cc-connect/agent/qoder"
)
+199
View File
@@ -0,0 +1,199 @@
//go:build blackbox
// Package p2 contains P2 blackbox tests — important features that don't block
// release but failures must be recorded.
//
// This file covers engine-dispatched slash commands:
//
// /whoami, /agent-sid, /skills, /cron list, /quiet, /effort, /search
//
// and security guard: non-authorized user rejection.
//
// Run:
//
// CC_BLACKBOX_CLAUDECODE_API_KEY=xxx \
// go test -tags blackbox ./tests/blackbox/p2/... -timeout 600s -v
package p2
import (
"fmt"
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
bbplatform "github.com/chenhg5/cc-connect/tests/blackbox/platform"
)
const p2CmdTimeout = 30 * time.Second
// ── P2-41: /whoami ────────────────────────────────────────────────────────────
func TestP2_41_Whoami_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
reply := env.SendWithTimeout("/whoami", p2CmdTimeout)
// Should contain the user ID we injected ("user1") or user name.
assertContainsAny(t, "P2-41 /whoami", reply.Text(),
"user1", "test-user", "user", "身份", "ID")
t.Logf("P2-41 OK: %q", truncate(reply.Text(), 150))
}
// ── P2-45: /agent-sid ────────────────────────────────────────────────────────
func TestP2_45_AgentSid_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
// /agent-sid requires an active session first.
env.Send("say hi briefly")
reply := env.SendWithTimeout("/agent-sid", p2CmdTimeout)
// Should contain a session ID of some kind.
text := reply.Text()
hasSessionID := strings.ContainsAny(text, "-") && len(text) > 5
if !hasSessionID && !strings.Contains(strings.ToLower(text), "session") &&
!strings.Contains(strings.ToLower(text), "no session") {
t.Errorf("P2-45 /agent-sid: response doesn't look like a session ID\ngot: %q", text)
}
t.Logf("P2-45 OK: %q", truncate(text, 150))
}
// ── P2-38: /skills ───────────────────────────────────────────────────────────
func TestP2_38_Skills_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
reply := env.SendWithTimeout("/skills", p2CmdTimeout)
assertContainsAny(t, "P2-38 /skills", strings.ToLower(reply.Text()),
"skill", "no skill", "没有", "available", "技能")
t.Logf("P2-38 OK: %q", truncate(reply.Text(), 200))
}
// ── P2-56 + P2-57: /cron list and /cron add ──────────────────────────────────
func TestP2_56_CronList_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
reply := env.SendWithTimeout("/cron list", p2CmdTimeout)
assertContainsAny(t, "P2-56 /cron list", strings.ToLower(reply.Text()),
"cron", "job", "task", "schedule", "no cron", "没有", "定时")
t.Logf("P2-56 OK: %q", truncate(reply.Text(), 200))
}
func TestP2_57_CronAdd_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
// Add a cron job.
addReply := env.SendWithTimeout(
`/cron add --cron "0 9 * * 1" --prompt "Weekly status summary" --desc "weekly-test"`,
p2CmdTimeout,
)
t.Logf("/cron add reply: %q", truncate(addReply.Text(), 200))
// /cron list should now show the job.
listReply := env.SendWithTimeout("/cron list", p2CmdTimeout)
hasEntry := strings.Contains(listReply.Text(), "weekly-test") ||
strings.Contains(listReply.Text(), "Weekly") ||
strings.Contains(listReply.Text(), "0 9 * * 1")
if !hasEntry {
t.Errorf("P2-57 /cron add: job not found in /cron list\n/cron list: %q", listReply.Text())
}
t.Logf("P2-57 OK: cron job appears in /cron list")
}
// ── P2-40: /quiet ────────────────────────────────────────────────────────────
func TestP2_40_Quiet_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
reply := env.SendWithTimeout("/quiet", p2CmdTimeout)
assertContainsAny(t, "P2-40 /quiet", strings.ToLower(reply.Text()),
"quiet", "silent", "mode", "安静", "模式", "enabled", "disabled", "on", "off")
t.Logf("P2-40 OK: %q", truncate(reply.Text(), 150))
}
// ── P2-39: /effort ───────────────────────────────────────────────────────────
func TestP2_39_Effort_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
reply := env.SendWithTimeout("/effort high", p2CmdTimeout)
assertContainsAny(t, "P2-39 /effort high", strings.ToLower(reply.Text()),
"effort", "high", "reasoning", "思考", "努力", "switched", "changed", "not supported", "支持")
t.Logf("P2-39 OK: %q", truncate(reply.Text(), 150))
}
// ── P2-46: /search ───────────────────────────────────────────────────────────
func TestP2_46_Search_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
// Create a session with a known keyword.
env.Send("the keyword is XYZZY42")
// Search for the keyword.
reply := env.SendWithTimeout("/search XYZZY42", p2CmdTimeout)
assertContainsAny(t, "P2-46 /search", strings.ToLower(reply.Text()),
"xyzzy42", "session", "result", "found", "no result", "没有", "搜索")
t.Logf("P2-46 OK: %q", truncate(reply.Text(), 200))
}
// ── P2-61: 非授权用户被拒绝 ──────────────────────────────────────────────────
// TestP2_61_UnauthorizedUserIgnored verifies that when a message arrives from
// a user ID not in allow_from, cc-connect does NOT send a reply.
//
// The engine's allow_from filter is configured per-project. This test uses a
// separate MockPlatform that injects a message from a user NOT in any
// allow_from list. The platform should receive no reply.
//
// NOTE: This test requires allow_from to be configured. Since the blackbox
// test engine is created without allow_from config, we CANNOT test rejection
// here — this is a configuration-level feature. Marking as informational.
func TestP2_61_AllowFromDocumented(t *testing.T) {
t.Log("P2-61 (allow_from): requires platform-level allow_from config.")
t.Log("Verified manually via IM with non-whitelisted Telegram user ID.")
t.Log("Cannot automate without modifying engine config per-test.")
// Not a failure — documenting the gap.
}
// ── P2-47: /bind ─────────────────────────────────────────────────────────────
func TestP2_47_Bind_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
reply := env.SendWithTimeout("/bind setup", p2CmdTimeout)
assertContainsAny(t, "P2-47 /bind setup", strings.ToLower(reply.Text()),
"bind", "relay", "setup", "绑定", "不支持", "not supported", "project")
t.Logf("P2-47 OK: %q", truncate(reply.Text(), 200))
}
// ── helpers ───────────────────────────────────────────────────────────────────
func assertContainsAny(t *testing.T, label, text string, candidates ...string) {
t.Helper()
lower := strings.ToLower(text)
for _, c := range candidates {
if strings.Contains(lower, strings.ToLower(c)) {
return
}
}
t.Errorf("%s: none of %v found\ngot: %q", label, candidates, text)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "…"
}
// sendToNewPlatform injects a message from a fresh user to the existing engine
// via a second mock platform. Used for multi-user test scenarios.
func sendToNewPlatform(_ *bbplatform.MockPlatform, userID, content string) {
// placeholder — used in custom command tests below
_ = fmt.Sprintf("user %s: %s", userID, content)
}
+407
View File
@@ -0,0 +1,407 @@
//go:build blackbox
package p2
// Configuration switch tests — P2-71 to P2-86.
//
// These tests verify that engine-level config options produce the expected
// observable output difference. They correspond to the checklist items that
// require restarting with a different config (P2-71..P2-88).
//
// Unlike the manual checklist process (apply config → IM verify → revert),
// these tests use NewEnvWithSetup to configure the engine BEFORE start,
// making them fully automated.
//
// Tests are split into two tiers:
//
// FAST (no agent API call needed — engine handles before forwarding):
// - P2-81: disabled_commands → blocked immediately by engine
// - P2-82: banned_words → blocked immediately by engine
// - P2-40b: rate_limit → throttled immediately by engine
//
// SLOW (agent API call required — verifying output format):
// - P2-71: show_context_indicator = false → no [ctx: ~N%] in replies
// - P2-77: display.mode = "compact" → no thinking/tool messages
// - P2-78: thinking_messages = false → no 💭 thinking messages
// - P2-79: tool_messages = false → no 🔧 tool messages
import (
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
)
const cfgCmdTimeout = 30 * time.Second
// ── P2-81: disabled_commands ──────────────────────────────────────────────────
// TestP2_81_DisabledCommands verifies that a disabled command is blocked by the
// engine before it reaches the agent. This test is FAST — no agent API call.
func TestP2_81_DisabledCommands(t *testing.T) {
t.Parallel()
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
e.SetDisabledCommands([]string{"help", "restart", "shell"})
})
// /help should be blocked.
helpReply := env.SendWithTimeout("/help", cfgCmdTimeout)
if !strings.Contains(helpReply.Text(), "disabled") && !strings.Contains(helpReply.Text(), "禁用") {
t.Errorf("P2-81: /help not blocked; got: %q", helpReply.Text())
}
t.Logf("P2-81a /help blocked: %q", truncate(helpReply.Text(), 100))
// /restart should be blocked too.
restartReply := env.SendWithTimeout("/restart", cfgCmdTimeout)
if !strings.Contains(restartReply.Text(), "disabled") && !strings.Contains(restartReply.Text(), "禁用") {
t.Errorf("P2-81: /restart not blocked; got: %q", restartReply.Text())
}
t.Logf("P2-81b /restart blocked: %q", truncate(restartReply.Text(), 100))
// /list should NOT be blocked (it's not in the disabled list).
listReply := env.SendWithTimeout("/list", cfgCmdTimeout)
if strings.Contains(listReply.Text(), "disabled") {
t.Errorf("P2-81: /list incorrectly blocked; got: %q", listReply.Text())
}
t.Logf("P2-81 OK: /help and /restart disabled, /list still works")
}
// ── P2-82: banned_words ───────────────────────────────────────────────────────
// TestP2_82_BannedWords verifies that messages containing a banned word are
// intercepted by the engine before reaching the agent. FAST — no agent API call.
func TestP2_82_BannedWords(t *testing.T) {
t.Parallel()
const bannedWord = "XBLACKBOXBANWORD"
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
e.SetBannedWords([]string{bannedWord})
})
// Message with banned word → should be blocked.
blockedReply := env.SendWithTimeout(
"Please help me with "+bannedWord+" testing.",
cfgCmdTimeout,
)
assertContainsAny(t, "P2-82 banned reply", blockedReply.Text(),
"blocked", "prohibited", "违禁", "拦截", "⚠️")
t.Logf("P2-82a banned word blocked: %q", truncate(blockedReply.Text(), 100))
// Normal message → should pass through (agent responds).
normalReply := env.Send("say hi briefly")
if strings.TrimSpace(normalReply.Text()) == "" {
t.Errorf("P2-82: normal message got empty reply after banned-word config")
}
if strings.Contains(strings.ToLower(normalReply.Text()), "prohibited") ||
strings.Contains(strings.ToLower(normalReply.Text()), "blocked") {
t.Errorf("P2-82: normal message incorrectly blocked; got: %q", normalReply.Text())
}
t.Logf("P2-82 OK: banned word blocked, normal message passes")
}
// ── P2-82b: banned_words case-insensitive ────────────────────────────────────
// TestP2_82b_BannedWordsCaseInsensitive checks that banned word matching is
// case-insensitive (lowercase config word, uppercase in message).
func TestP2_82b_BannedWordsCaseInsensitive(t *testing.T) {
t.Parallel()
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
// Register the word in lowercase.
e.SetBannedWords([]string{"casetest_banned"})
})
// Message uses UPPERCASE version.
reply := env.SendWithTimeout("help with CASETEST_BANNED scenario", cfgCmdTimeout)
assertContainsAny(t, "P2-82b case-insensitive ban", reply.Text(),
"blocked", "prohibited", "违禁", "拦截", "⚠️")
t.Logf("P2-82b OK: case-insensitive banned word detected")
}
// ── P2-71: show_context_indicator = false ────────────────────────────────────
// TestP2_71_HideContextIndicator verifies that when show_context_indicator is
// false, the [ctx: ~N%] suffix is absent from agent replies.
//
// This test is SLOW — it requires a real agent turn to produce final output.
// The context indicator only appears when input_tokens >= 100, so we send
// a few messages first to build up token history.
func TestP2_71_HideContextIndicator_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
e.SetShowContextIndicator(false)
})
// Send several messages to accumulate token history (ctx indicator threshold).
for _, msg := range []string{
"My name is BlackboxConfigTest.",
"I am testing context indicator behavior.",
"say hi briefly", // this turn we check
} {
msgs := env.SendComplete(msg)
combined := helper.AnyText(msgs)
if strings.Contains(combined, "[ctx:") {
t.Errorf("P2-71: [ctx: ~N%%] appeared when show_context_indicator=false\nreply: %q",
combined)
}
}
t.Logf("P2-71 OK: no [ctx:] suffix observed with show_context_indicator=false")
}
// TestP2_71b_ShowContextIndicator verifies that with the DEFAULT setting
// (show_context_indicator = true), the [ctx: ~N%] suffix eventually appears
// after sufficient token accumulation.
func TestP2_71b_ShowContextIndicator_ClaudeCode(t *testing.T) {
t.Parallel()
// Default env: show_context_indicator = true
env := helper.NewEnv(t, "claudecode")
// Send multiple turns to push token count above the 100-token threshold.
// The indicator appears when input_tokens >= 100.
const maxTurns = 5
found := false
for i := 0; i < maxTurns; i++ {
msgs := env.SendComplete("say one sentence about software testing")
combined := helper.AnyText(msgs)
if strings.Contains(combined, "[ctx:") {
found = true
t.Logf("P2-71b: [ctx:] appeared at turn %d: %q", i+1, truncate(combined, 200))
break
}
}
if !found {
t.Logf("P2-71b: [ctx:] not seen in %d turns — token count may not have reached threshold yet", maxTurns)
// Not a hard failure — threshold is token-count dependent.
} else {
t.Logf("P2-71b OK: [ctx:] appears with default show_context_indicator=true")
}
}
// ── P2-78: thinking_messages = false ─────────────────────────────────────────
// TestP2_78_HideThinkingMessages verifies that when thinking_messages=false,
// no thinking indicators (💭 / thinking prefix) appear in the output.
//
// SLOW — requires agent with thinking enabled (complex question).
func TestP2_78_HideThinkingMessages_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
e.SetDisplayConfig(core.DisplayCfg{
Mode: "full",
CardMode: "legacy",
ThinkingMessages: false, // ← key config
ThinkingMaxLen: 300,
ToolMessages: true,
ToolMaxLen: 500,
})
})
// A question that might trigger thinking.
msgs := env.SendComplete("What is 17 * 23? Show your reasoning.")
combined := helper.AnyText(msgs)
// Thinking messages are prefixed with 💭 or contain "thinking" marker.
// When ThinkingMessages=false, these should NOT appear.
for _, msg := range msgs {
text := msg.Text()
if strings.HasPrefix(text, "💭") || strings.Contains(text, "\n💭") {
t.Errorf("P2-78: thinking message appeared with ThinkingMessages=false\nmsg: %q", text)
}
}
t.Logf("P2-78 OK: no thinking messages in %d msgs", len(msgs))
t.Logf("combined reply: %q", truncate(combined, 300))
}
// ── P2-79: tool_messages = false ─────────────────────────────────────────────
// TestP2_79_HideToolMessages verifies that when tool_messages=false, no tool
// progress messages (🔧 / tool invocation indicators) appear.
//
// SLOW — requires a real agent that calls a tool.
func TestP2_79_HideToolMessages_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
e.SetDisplayConfig(core.DisplayCfg{
Mode: "full",
CardMode: "legacy",
ThinkingMessages: true,
ThinkingMaxLen: 300,
ToolMessages: false, // ← key config
ToolMaxLen: 500,
})
})
msgs := env.SendComplete("List the files in the current directory. Use a shell command.")
combined := helper.AnyText(msgs)
// Tool messages are prefixed with 🔧 or contain "Tool" markers.
for _, msg := range msgs {
text := msg.Text()
if strings.HasPrefix(text, "🔧") || strings.Contains(text, "\n🔧") {
t.Errorf("P2-79: tool message appeared with ToolMessages=false\nmsg: %q", text)
}
}
// Verify the agent still produced a useful final response (tool was called
// but progress hidden).
hasResult := strings.Contains(combined, ".go") ||
strings.Contains(strings.ToLower(combined), "file") ||
strings.Contains(strings.ToLower(combined), "directory") ||
strings.Contains(strings.ToLower(combined), "no files")
if !hasResult {
t.Errorf("P2-79: agent didn't produce file listing result\ncombined: %q", combined)
}
t.Logf("P2-79 OK: no 🔧 tool messages; final result has file listing")
}
// ── P2-77: display.mode = "compact" ──────────────────────────────────────────
// TestP2_77_DisplayModeCompact verifies that compact mode hides thinking/tool
// messages. Unlike full mode, compact sends each text segment separately but
// suppresses intermediate tool/thinking indicators.
//
// SLOW — requires agent turn.
func TestP2_77_DisplayModeCompact_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
e.SetDisplayConfig(core.DisplayCfg{
Mode: "compact", // ← key config
CardMode: "legacy",
ThinkingMessages: true,
ThinkingMaxLen: 300,
ToolMessages: true,
ToolMaxLen: 500,
})
})
msgs := env.SendComplete("List the files in the current directory. Use a shell command.")
// In compact mode, 💭 thinking and 🔧 tool messages should not appear.
for _, msg := range msgs {
text := msg.Text()
if strings.HasPrefix(text, "💭") || strings.HasPrefix(text, "🔧") {
t.Errorf("P2-77 compact mode: thinking/tool message appeared\nmsg: %q", text)
}
}
t.Logf("P2-77 OK: no thinking/tool messages in compact mode (%d msgs)", len(msgs))
}
// ── P2-80: stream_preview disabled ───────────────────────────────────────────
// TestP2_80_StreamPreviewDisabled verifies that disabling stream_preview does
// not break message delivery. The agent's final response must still arrive.
//
// Note: MockPlatform does NOT implement MessageUpdater, so streaming preview
// would not occur regardless. This test verifies that disabling stream_preview
// does not suppress the final reply.
func TestP2_80_StreamPreviewDisabled_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
e.SetStreamPreviewCfg(core.StreamPreviewCfg{
Enabled: false,
IntervalMs: 1500,
MinDeltaChars: 30,
MaxChars: 2000,
})
})
msgs := env.SendComplete("say hi briefly")
combined := helper.AnyText(msgs)
if strings.TrimSpace(combined) == "" {
t.Errorf("P2-80: stream_preview=false suppressed final reply")
}
t.Logf("P2-80 OK: final reply still delivered with stream_preview=false: %q",
truncate(combined, 150))
}
// ── P2-85/86: reply_footer ────────────────────────────────────────────────────
// TestP2_86_HideReplyFooter verifies that with reply_footer=false, the
// footer information does not appear in final replies.
// When reply_footer is false AND show_context_indicator is false, the
// assistant reply should have no trailing metadata line.
func TestP2_86_HideReplyFooter_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
e.SetReplyFooterEnabled(false)
e.SetShowContextIndicator(false) // also hide ctx
})
msgs := env.SendComplete("say hi briefly")
for _, msg := range msgs {
text := msg.Text()
// Footer would contain model name or work dir info.
// With both disabled, last line should not look like a footer.
lines := strings.Split(strings.TrimSpace(text), "\n")
if len(lines) > 0 {
lastLine := strings.TrimSpace(lines[len(lines)-1])
// Footer typically contains: "model · /path/to/dir" or "[ctx: ~N%]"
if strings.Contains(lastLine, "[ctx:") {
t.Errorf("P2-86: [ctx:] appeared with show_context_indicator=false\nlast line: %q", lastLine)
}
}
}
t.Logf("P2-86 OK: no footer/ctx in replies with reply_footer=false, show_context_indicator=false")
}
// ── P1-40: filter_external_sessions = false (default) ─────────────────────────
// TestP1_40_FilterExternalSessionsDefault verifies that the default behavior
// (filter_external_sessions=false) shows ALL sessions including any created
// externally (not via cc-connect).
//
// Since our MockPlatform tests don't have external sessions, we verify that
// the flag doesn't hide sessions WE created.
func TestP1_40_FilterExternalSessions_Default_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
e.SetFilterExternalSessions(false) // explicit default
})
env.SendComplete("say hi briefly")
env.SendWithTimeout("/new", cfgCmdTimeout)
env.SendComplete("say hello briefly")
listReply := env.SendWithTimeout("/list", cfgCmdTimeout)
count := strings.Count(listReply.Text(), "msgs")
if count < 2 {
t.Errorf("P1-40: filter_external_sessions=false still hides sessions (got %d)\n/list: %q",
count, listReply.Text())
}
t.Logf("P1-40 OK: %d sessions visible with filter_external_sessions=false", count)
}
// ── Instant reply ─────────────────────────────────────────────────────────────
// TestInstantReply verifies that when instant_reply is enabled, the engine
// sends an immediate confirmation before the agent processes the message.
func TestInstantReply_ClaudeCode(t *testing.T) {
t.Parallel()
const marker = "🤔 Working on it..."
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
e.SetInstantReply(core.InstantReplyCfg{
Enabled: true,
Content: marker,
})
})
before := env.Platform.MessageCount()
env.Platform.InjectMessage(helper.DefaultUser, helper.DefaultChat, "say hi briefly")
// The instant reply should arrive VERY quickly (within 2s), before the agent responds.
instant := env.Platform.WaitForReply(before, 2*time.Second)
if instant == nil {
// Could be that the agent replied faster than 2s — check all messages.
t.Logf("InstantReply: no reply in 2s (agent may have been faster)")
} else if instant.Text() == marker {
t.Logf("InstantReply OK: instant confirmation received before agent: %q", marker)
}
// Either way, we must eventually get a final substantive reply.
msgs, ok := env.Platform.WaitForN(1, helper.DefaultReplyTimeout)
if !ok {
t.Fatalf("InstantReply: no final reply received\nall messages:\n%s", env.Platform.AllText())
}
t.Logf("InstantReply OK: %d total messages received", len(msgs))
}
+128
View File
@@ -0,0 +1,128 @@
//go:build blackbox
package p2
// Custom commands and aliases — P2-63 to P2-70.
//
// These tests exercise the /commands and /alias engine features:
// P2-63: /commands add <name> <prompt>
// P2-64: invoke the custom command
// P2-65: /commands list
// P2-66: /commands del <name>
// P2-67: /commands addexec <name> <shell-cmd>
// P2-68: invoke the exec command
// P2-69: /alias add <alias> <target>
// P2-70: /alias del <alias>
import (
"strings"
"testing"
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
)
// TestP2_63_65_66_CustomCommandLifecycle tests the full custom prompt command
// lifecycle: add → list → invoke → delete → verify deleted.
func TestP2_63_65_66_CustomCommandLifecycle_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
const cmdName = "bb-test-cmd"
const cmdPrompt = "Say the word BLACKBOX_MARKER"
// P2-63: add.
addReply := env.SendWithTimeout("/commands add "+cmdName+" "+cmdPrompt, p2CmdTimeout)
t.Logf("P2-63 /commands add: %q", truncate(addReply.Text(), 150))
assertContainsAny(t, "P2-63 /commands add", strings.ToLower(addReply.Text()),
"created", "added", "success", "成功", cmdName)
// P2-65: list — new command should appear.
listReply := env.SendWithTimeout("/commands", p2CmdTimeout)
t.Logf("P2-65 /commands: %q", truncate(listReply.Text(), 200))
if !strings.Contains(listReply.Text(), cmdName) {
t.Errorf("P2-65: %q not in /commands list\n%q", cmdName, listReply.Text())
}
// P2-64: invoke the custom command.
invokeReply := env.Send("/" + cmdName)
t.Logf("P2-64 /%s: %q", cmdName, truncate(invokeReply.Text(), 200))
// The agent should say something in response to the prompt.
if strings.TrimSpace(invokeReply.Text()) == "" {
t.Errorf("P2-64: /%s produced empty reply", cmdName)
}
// P2-66: delete.
delReply := env.SendWithTimeout("/commands del "+cmdName, p2CmdTimeout)
t.Logf("P2-66 /commands del: %q", truncate(delReply.Text(), 150))
// Verify it's gone from the list.
listAfter := env.SendWithTimeout("/commands", p2CmdTimeout)
if strings.Contains(listAfter.Text(), cmdName) {
t.Errorf("P2-66: %q still in /commands list after del\n%q", cmdName, listAfter.Text())
}
t.Logf("P2-63..66 OK: custom command lifecycle complete")
}
// TestP2_67_68_ExecCommandLifecycle tests adding a shell exec command and
// verifying its output arrives correctly.
func TestP2_67_68_ExecCommandLifecycle_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
const execName = "bb-exec-test"
// P2-67: add exec command (echo a unique marker).
addReply := env.SendWithTimeout(
"/commands addexec "+execName+" echo EXEC_MARKER_77",
p2CmdTimeout,
)
t.Logf("P2-67 /commands addexec: %q", truncate(addReply.Text(), 150))
assertContainsAny(t, "P2-67 addexec", strings.ToLower(addReply.Text()),
"created", "added", "success", "成功", execName)
// P2-68: invoke exec command.
execReply := env.SendWithTimeout("/"+execName, p2CmdTimeout)
t.Logf("P2-68 /%s: %q", execName, truncate(execReply.Text(), 200))
if !strings.Contains(execReply.Text(), "EXEC_MARKER_77") {
t.Errorf("P2-68: /%s output missing EXEC_MARKER_77\ngot: %q", execName, execReply.Text())
}
// Cleanup.
env.SendWithTimeout("/commands del "+execName, p2CmdTimeout)
t.Logf("P2-67..68 OK: exec command invoked, output correct")
}
// TestP2_69_70_AliasLifecycle tests adding and removing an alias.
func TestP2_69_70_AliasLifecycle_ClaudeCode(t *testing.T) {
t.Parallel()
env := helper.NewEnv(t, "claudecode")
// P2-69: add alias "bbhelp" → "/help".
addReply := env.SendWithTimeout("/alias add bbhelp /help", p2CmdTimeout)
t.Logf("P2-69 /alias add: %q", truncate(addReply.Text(), 150))
assertContainsAny(t, "P2-69 /alias add", strings.ToLower(addReply.Text()),
"added", "created", "success", "成功", "alias", "bbhelp")
// Trigger the alias — should behave like /help.
aliasReply := env.SendWithTimeout("bbhelp", p2CmdTimeout)
t.Logf("alias bbhelp reply: %q", truncate(aliasReply.Text(), 200))
assertContainsAny(t, "P2-69 alias invocation", aliasReply.Text(),
"/new", "/list", "/stop", "/help", "Command", "命令", "Available")
// P2-70: delete alias.
delReply := env.SendWithTimeout("/alias del bbhelp", p2CmdTimeout)
t.Logf("P2-70 /alias del: %q", truncate(delReply.Text(), 150))
// Verify alias is gone — sending "bbhelp" should NOT trigger /help.
afterReply := env.Send("bbhelp")
afterText := strings.ToLower(afterReply.Text())
// After deletion, "bbhelp" is just a plain message passed to the agent.
// It should NOT produce a command list (which /help would).
triggeredHelp := strings.Contains(afterText, "/new") && strings.Contains(afterText, "/list") &&
strings.Contains(afterText, "/stop")
if triggeredHelp {
t.Errorf("P2-70: alias bbhelp still active after /alias del\ngot: %q", afterReply.Text())
}
t.Logf("P2-69..70 OK: alias lifecycle complete")
}
+336
View File
@@ -0,0 +1,336 @@
// Package platform provides the MockPlatform used in blackbox tests.
//
// MockPlatform implements core.Platform without connecting to any real IM
// service. Tests inject messages via InjectMessage and verify cc-connect's
// output via WaitForReply / GetSentMessages.
package platform
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/chenhg5/cc-connect/core"
)
// SentMessage captures a single outbound call from cc-connect to the platform.
type SentMessage struct {
Content string
Card *core.Card // non-nil when SendCard/ReplyCard was called
ReplyCtx any
At time.Time
}
// Text returns the displayable text of the message (card fallback if needed).
func (m *SentMessage) Text() string {
if m.Content != "" {
return m.Content
}
if m.Card != nil {
return m.Card.RenderText()
}
return ""
}
// MockReplyCtx is a simple reply context stored in injected messages.
type MockReplyCtx struct {
Platform string
ChatID string
UserID string
}
// MockPlatform implements core.Platform for blackbox tests.
// Messages are injected via InjectMessage; outbound calls (Reply, Send,
// SendCard, etc.) are recorded and retrievable for assertions.
type MockPlatform struct {
mu sync.Mutex
name string
handler core.MessageHandler
sent []*SentMessage
notify chan struct{}
}
// New creates a named MockPlatform ready for use in tests.
func New(name string) *MockPlatform {
return &MockPlatform{
name: name,
notify: make(chan struct{}, 1),
}
}
// ── core.Platform interface ──────────────────────────────────────────────────
func (p *MockPlatform) Name() string { return p.name }
func (p *MockPlatform) Start(handler core.MessageHandler) error {
p.handler = handler
return nil
}
func (p *MockPlatform) Stop() error { return nil }
func (p *MockPlatform) Reply(ctx context.Context, replyCtx any, content string) error {
p.record(&SentMessage{Content: content, ReplyCtx: replyCtx, At: time.Now()})
return nil
}
func (p *MockPlatform) Send(ctx context.Context, replyCtx any, content string) error {
return p.Reply(ctx, replyCtx, content)
}
// ── Optional platform interfaces ─────────────────────────────────────────────
func (p *MockPlatform) SendCard(ctx context.Context, replyCtx any, card *core.Card) error {
p.record(&SentMessage{Content: card.RenderText(), Card: card, ReplyCtx: replyCtx, At: time.Now()})
return nil
}
func (p *MockPlatform) ReplyCard(ctx context.Context, replyCtx any, card *core.Card) error {
return p.SendCard(ctx, replyCtx, card)
}
func (p *MockPlatform) SendWithButtons(ctx context.Context, replyCtx any, content string, buttons [][]core.ButtonOption) error {
return p.Reply(ctx, replyCtx, content)
}
func (p *MockPlatform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error {
p.record(&SentMessage{Content: "[image]", ReplyCtx: replyCtx, At: time.Now()})
return nil
}
func (p *MockPlatform) SendFile(ctx context.Context, replyCtx any, file core.FileAttachment) error {
p.record(&SentMessage{Content: fmt.Sprintf("[file: %s]", file.FileName), ReplyCtx: replyCtx, At: time.Now()})
return nil
}
// ── Message injection ────────────────────────────────────────────────────────
// InjectMessage simulates a user sending a plain-text message to cc-connect.
// userID and chatID form the session key; each unique (userID, chatID) pair is
// an independent session.
func (p *MockPlatform) InjectMessage(userID, chatID, content string) {
p.InjectMessageWithAttachments(userID, chatID, content, nil, nil)
}
// InjectMessageWithAttachments simulates a user sending a message with attachments.
func (p *MockPlatform) InjectMessageWithAttachments(
userID, chatID, content string,
images []core.ImageAttachment,
files []core.FileAttachment,
) {
msg := &core.Message{
Platform: p.name,
SessionKey: fmt.Sprintf("%s:%s:%s", p.name, chatID, userID),
MessageID: fmt.Sprintf("msg_%d_%s", time.Now().UnixNano(), userID),
UserID: userID,
UserName: "test-user-" + userID,
ChatName: chatID,
Content: content,
Images: images,
Files: files,
ReplyCtx: &MockReplyCtx{Platform: p.name, ChatID: chatID, UserID: userID},
}
// Run in goroutine, matching how real platforms deliver messages.
go p.handler(p, msg)
}
// ── Assertion helpers ────────────────────────────────────────────────────────
// WaitForTurnComplete waits until the message stream has been "stable" —
// no new messages — for at least idlePeriod, then returns all messages
// received since startIdx. This is the right way to wait for a full agent
// turn when cc-connect may send multiple messages per turn (thinking indicators,
// progress updates, then the final reply).
//
// A good idlePeriod for real agents is 3-5s; set timeout to your test deadline.
func (p *MockPlatform) WaitForTurnComplete(startIdx int, idlePeriod, timeout time.Duration) []*SentMessage {
deadline := time.Now().Add(timeout)
// We must first receive at least one message after startIdx.
if !func() bool {
for {
p.mu.Lock()
if len(p.sent) > startIdx {
p.mu.Unlock()
return true
}
p.mu.Unlock()
remaining := time.Until(deadline)
if remaining <= 0 {
return false
}
select {
case <-p.notify:
case <-time.After(remaining):
}
}
}() {
return nil
}
// Now wait for stability: no new messages for idlePeriod.
for {
lastCount := p.MessageCount()
wait := idlePeriod
if time.Until(deadline) < wait {
wait = time.Until(deadline)
}
if wait <= 0 {
break
}
select {
case <-p.notify:
// New message arrived — reset stability window.
continue
case <-time.After(wait):
}
// Check if count changed during the wait (notify is non-blocking so
// we might have missed a signal).
if p.MessageCount() == lastCount {
break // stable
}
}
p.mu.Lock()
out := append([]*SentMessage(nil), p.sent[startIdx:]...)
p.mu.Unlock()
return out
}
// WaitForReply waits until at least one new message is sent after startIdx,
// then returns it. Returns nil on timeout.
func (p *MockPlatform) WaitForReply(startIdx int, timeout time.Duration) *SentMessage {
deadline := time.Now().Add(timeout)
for {
p.mu.Lock()
if len(p.sent) > startIdx {
msg := p.sent[startIdx]
p.mu.Unlock()
return msg
}
p.mu.Unlock()
remaining := time.Until(deadline)
if remaining <= 0 {
return nil
}
// Block until a new message arrives or timeout.
select {
case <-p.notify:
case <-time.After(remaining):
}
}
}
// WaitForN waits until at least n messages have been sent in total.
// Returns the current slice when satisfied, or times out.
func (p *MockPlatform) WaitForN(n int, timeout time.Duration) ([]*SentMessage, bool) {
deadline := time.Now().Add(timeout)
for {
p.mu.Lock()
if len(p.sent) >= n {
out := append([]*SentMessage(nil), p.sent...)
p.mu.Unlock()
return out, true
}
p.mu.Unlock()
remaining := time.Until(deadline)
if remaining <= 0 {
return p.GetSentMessages(), false
}
select {
case <-p.notify:
case <-time.After(remaining):
}
}
}
// WaitForMessageContaining waits until any message (starting from startIdx)
// contains the given substring (case-insensitive). Returns the matching message
// or nil on timeout.
func (p *MockPlatform) WaitForMessageContaining(startIdx int, substr string, timeout time.Duration) *SentMessage {
deadline := time.Now().Add(timeout)
for {
p.mu.Lock()
for i := startIdx; i < len(p.sent); i++ {
if strings.Contains(strings.ToLower(p.sent[i].Text()), strings.ToLower(substr)) {
msg := p.sent[i]
p.mu.Unlock()
return msg
}
}
p.mu.Unlock()
remaining := time.Until(deadline)
if remaining <= 0 {
return nil
}
select {
case <-p.notify:
case <-time.After(remaining):
}
}
}
// MessageCount returns the current number of sent messages.
func (p *MockPlatform) MessageCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.sent)
}
// GetSentMessages returns a snapshot of all sent messages.
func (p *MockPlatform) GetSentMessages() []*SentMessage {
p.mu.Lock()
defer p.mu.Unlock()
return append([]*SentMessage(nil), p.sent...)
}
// GetLastMessage returns the most recent sent message, or nil if none.
func (p *MockPlatform) GetLastMessage() *SentMessage {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.sent) == 0 {
return nil
}
return p.sent[len(p.sent)-1]
}
// AllText returns all sent message texts joined by newline, useful for
// debugging failed assertions.
func (p *MockPlatform) AllText() string {
p.mu.Lock()
defer p.mu.Unlock()
parts := make([]string, len(p.sent))
for i, m := range p.sent {
parts[i] = m.Text()
}
return strings.Join(parts, "\n---\n")
}
// Reset clears all recorded messages. Use between sub-scenarios within one
// test where you need a clean slate.
func (p *MockPlatform) Reset() {
p.mu.Lock()
defer p.mu.Unlock()
p.sent = nil
}
// ── internal ─────────────────────────────────────────────────────────────────
func (p *MockPlatform) record(m *SentMessage) {
p.mu.Lock()
p.sent = append(p.sent, m)
p.mu.Unlock()
// Non-blocking notify: if a waiter is already queued, it will pick up the
// message on its next iteration.
select {
case p.notify <- struct{}{}:
default:
}
}