Files
2026-06-02 23:14:41 +08:00

444 lines
15 KiB
Go

// 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 ""
}