初始化仓库
This commit is contained in:
@@ -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"
|
||||
)
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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] + "…"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user