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

451 lines
14 KiB
Go

//go:build integration
package integration
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/config"
"github.com/chenhg5/cc-connect/core"
)
// testConfigPath returns the path to config.test.toml co-located with this
// test file. Override via CC_TEST_CONFIG env var.
func testConfigPath(t *testing.T) string {
t.Helper()
if p := os.Getenv("CC_TEST_CONFIG"); p != "" {
return p
}
_, thisFile, _, _ := runtime.Caller(0)
return filepath.Join(filepath.Dir(thisFile), "config.test.toml")
}
// setupE2E loads config.test.toml, finds the named project, creates a real
// agent with provider wiring, and returns Engine + mockPlatform. All work_dir
// and session state use t.TempDir() for full isolation.
func setupE2E(t *testing.T, projectName string) (*core.Engine, *mockPlatform, func()) {
t.Helper()
cfgPath := testConfigPath(t)
if _, err := os.Stat(cfgPath); err != nil {
t.Skipf("skip: test config %s not found (copy config.test.toml.example and fill in credentials)", cfgPath)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("cannot load test config: %v", err)
}
var proj *config.ProjectConfig
for i := range cfg.Projects {
if cfg.Projects[i].Name == projectName {
proj = &cfg.Projects[i]
break
}
}
if proj == nil {
t.Fatalf("project %q not found in test config", projectName)
}
agentType := proj.Agent.Type
bin, err := findAgentBin(agentType)
if err != nil {
t.Skipf("skip: %v", err)
}
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("skip: %s binary not in PATH", bin)
}
workDir := t.TempDir()
opts := make(map[string]any)
for k, v := range proj.Agent.Options {
opts[k] = v
}
opts["work_dir"] = workDir
agent, err := core.CreateAgent(agentType, opts)
if err != nil {
t.Skipf("skip: cannot create agent: %v", err)
}
if ps, ok := agent.(core.ProviderSwitcher); ok {
var providers []core.ProviderConfig
for _, ref := range proj.Agent.ProviderRefs {
for _, gp := range cfg.Providers {
if gp.Name == ref {
providers = append(providers, configProviderToCore(gp))
break
}
}
}
if len(providers) > 0 {
ps.SetProviders(providers)
if provName, _ := opts["provider"].(string); provName != "" {
ps.SetActiveProvider(provName)
} else {
ps.SetActiveProvider(providers[0].Name)
}
}
}
mp := &mockPlatform{agent: agent}
sessPath := filepath.Join(workDir, "sessions.json")
e := core.NewEngine("e2e-test", agent, []core.Platform{mp}, sessPath, core.LangEnglish)
return e, mp, func() {
agent.Stop()
e.Stop()
}
}
type e2eHelper struct {
t *testing.T
e *core.Engine
mp *mockPlatform
uk string
}
func (h *e2eHelper) send(content string) {
h.mp.clear()
h.e.ReceiveMessage(h.mp, &core.Message{
SessionKey: h.uk, Platform: "mock", UserID: "e2e-user",
UserName: "tester", Content: content, ReplyCtx: "ctx",
})
}
func (h *e2eHelper) waitReply(timeout time.Duration) string {
msgs, ok := waitForMessages(h.mp, 1, timeout)
if !ok {
h.t.Fatalf("timeout waiting for reply; sent so far: %v", h.mp.getSent())
}
reply := joinMsgContent(msgs)
if strings.Contains(strings.ToLower(reply), "auth") && strings.Contains(strings.ToLower(reply), "balance") {
h.t.Skipf("skip: provider auth/balance error: %s", reply)
}
return reply
}
func (h *e2eHelper) waitCmd(timeout time.Duration) string {
return h.waitReply(timeout)
}
func (h *e2eHelper) sendAndWait(content string, timeout time.Duration) string {
h.send(content)
return h.waitReply(timeout)
}
// countSessions counts the "msgs" markers in /list output (each session line
// contains "N msgs"), giving us the session count.
func countSessions(listOutput string) int {
return strings.Count(listOutput, "msgs")
}
// ---------------------------------------------------------------------------
// Comprehensive E2E: covers /list /new /name /switch /delete /current /stop
// Each test function runs against ONE agent type. We define two entry points
// (Codex, ClaudeCode) so both are exercised. Skips gracefully if binary or
// config is missing.
// ---------------------------------------------------------------------------
func runE2E_FullSessionCommands(t *testing.T, project string) {
e, mp, cleanup := setupE2E(t, project)
defer cleanup()
h := &e2eHelper{t: t, e: e, mp: mp, uk: sessionKey("e2e-cmds")}
// ────── 1. First message → agent replies ──────
t.Log("[1] sending first message")
reply := h.sendAndWait("respond with exactly: E2E_PING", 90*time.Second)
t.Logf("[1] reply: %.120s", reply)
// ────── 2. /list → 1 session ──────
t.Log("[2] /list")
list1 := h.sendAndWait("/list", 10*time.Second)
c1 := countSessions(list1)
if c1 < 1 {
t.Fatalf("[2] /list should show >= 1 session, got %d\n%s", c1, list1)
}
t.Logf("[2] /list → %d session(s)", c1)
// ────── 3. /current ──────
t.Log("[3] /current")
cur := h.sendAndWait("/current", 10*time.Second)
if !strings.Contains(strings.ToLower(cur), "session") && !strings.Contains(strings.ToLower(cur), "s1") {
t.Logf("[3] /current output: %s", cur)
}
t.Logf("[3] /current OK")
// ────── 4. /new with custom name ──────
t.Log("[4] /new my-named-session")
h.sendAndWait("/new my-named-session", 10*time.Second)
t.Log("[4] /new done")
// ────── 5. Chat in new session → agent replies ──────
t.Log("[5] message in new session")
reply5 := h.sendAndWait("respond with exactly: E2E_PONG", 90*time.Second)
t.Logf("[5] reply: %.120s", reply5)
// ────── 6. /list → 2 sessions, name visible ──────
t.Log("[6] /list (should show 2 sessions)")
list2 := h.sendAndWait("/list", 10*time.Second)
c2 := countSessions(list2)
if c2 < 2 {
t.Fatalf("[6] /list should show >= 2 sessions, got %d\n%s", c2, list2)
}
if !strings.Contains(list2, "my-named-session") {
t.Errorf("[6] /list should show session name 'my-named-session'\n%s", list2)
}
t.Logf("[6] /list → %d sessions, name OK", c2)
// ────── 7. /name rename current session ──────
t.Log("[7] /name renamed-session")
nameReply := h.sendAndWait("/name renamed-session", 10*time.Second)
t.Logf("[7] /name reply: %.80s", nameReply)
// ────── 8. /list → verify renamed ──────
t.Log("[8] /list after rename")
list3 := h.sendAndWait("/list", 10*time.Second)
if !strings.Contains(list3, "renamed-session") {
t.Errorf("[8] /list should show 'renamed-session' after rename\n%s", list3)
}
t.Log("[8] rename confirmed in /list")
// ────── 9. /switch back to session 1 ──────
t.Log("[9] /switch 1")
switchReply := h.sendAndWait("/switch 1", 10*time.Second)
t.Logf("[9] /switch: %.80s", switchReply)
// ────── 10. Send message in session 1 → verify context ──────
t.Log("[10] message in session 1")
reply10 := h.sendAndWait("respond with exactly: BACK_TO_S1", 90*time.Second)
t.Logf("[10] reply: %.120s", reply10)
// ────── 11. /list → still 2 sessions ──────
t.Log("[11] /list (still 2 sessions after /switch)")
list4 := h.sendAndWait("/list", 10*time.Second)
c4 := countSessions(list4)
if c4 < 2 {
t.Errorf("[11] /list should still show >= 2 sessions, got %d\n%s", c4, list4)
}
t.Logf("[11] /list → %d sessions", c4)
// ────── 12. /new → third session ──────
t.Log("[12] /new third-session")
h.sendAndWait("/new third-session", 10*time.Second)
t.Log("[12] /new done")
// ────── 13. Chat in third session ──────
t.Log("[13] message in third session")
h.sendAndWait("respond with exactly: THIRD_OK", 90*time.Second)
t.Log("[13] reply received")
// ────── 14. /delete third session ──────
t.Log("[14] /delete 3")
delReply := h.sendAndWait("/delete 3", 10*time.Second)
t.Logf("[14] /delete: %.80s", delReply)
// ────── 15. /list → session deleted ──────
t.Log("[15] /list after /delete")
list5 := h.sendAndWait("/list", 10*time.Second)
c5 := countSessions(list5)
if c5 >= c4+1 {
t.Errorf("[15] /list should have fewer sessions after delete, got %d (was %d+1)\n%s", c5, c4, list5)
}
t.Logf("[15] /list → %d sessions (after delete)", c5)
// ────── 16. /stop ──────
t.Log("[16] /stop")
h.send("/stop")
time.Sleep(2 * time.Second)
t.Log("[16] /stop sent, no crash")
// ────── 17. /status ──────
t.Log("[17] /status")
statusReply := h.sendAndWait("/status", 10*time.Second)
t.Logf("[17] /status: %.120s", statusReply)
t.Log("=== ALL STEPS PASSED ===")
}
func TestE2E_Codex_FullSessionCommands(t *testing.T) {
runE2E_FullSessionCommands(t, "e2e-codex")
}
func TestE2E_ClaudeCode_FullSessionCommands(t *testing.T) {
runE2E_FullSessionCommands(t, "e2e-claudecode")
}
// ---------------------------------------------------------------------------
// E2E: /provider switch (requires multiple providers in config)
// ---------------------------------------------------------------------------
func runE2E_ProviderSwitch(t *testing.T, project string) {
e, mp, cleanup := setupE2E(t, project)
defer cleanup()
h := &e2eHelper{t: t, e: e, mp: mp, uk: sessionKey("e2e-provider")}
// Start a session
t.Log("[1] initial message")
h.sendAndWait("respond with exactly: PROV_INIT", 90*time.Second)
// /provider list
t.Log("[2] /provider list")
provList := h.sendAndWait("/provider list", 10*time.Second)
t.Logf("[2] providers: %.200s", provList)
// /provider switch to a different provider
t.Log("[3] /provider switch shengsuanyun")
switchReply := h.sendAndWait("/provider switch shengsuanyun", 10*time.Second)
t.Logf("[3] switch: %.120s", switchReply)
// Verify new provider works
t.Log("[4] message after switch")
reply4 := h.sendAndWait("respond with exactly: PROV_SWITCHED", 90*time.Second)
t.Logf("[4] reply: %.120s", reply4)
// /list should still work
t.Log("[5] /list after provider switch")
list := h.sendAndWait("/list", 10*time.Second)
if countSessions(list) < 1 {
t.Errorf("[5] /list broken after provider switch\n%s", list)
}
t.Log("[5] /list OK after provider switch")
t.Log("=== PROVIDER SWITCH PASSED ===")
}
func TestE2E_Codex_ProviderSwitch(t *testing.T) {
runE2E_ProviderSwitch(t, "e2e-codex")
}
func TestE2E_ClaudeCode_ProviderSwitch(t *testing.T) {
runE2E_ProviderSwitch(t, "e2e-claudecode")
}
// ---------------------------------------------------------------------------
// E2E: Session persistence across restart (simulate by recreating engine)
// ---------------------------------------------------------------------------
func runE2E_SessionPersistence(t *testing.T, project string) {
cfgPath := testConfigPath(t)
if _, err := os.Stat(cfgPath); err != nil {
t.Skipf("skip: test config not found")
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("cannot load config: %v", err)
}
var proj *config.ProjectConfig
for i := range cfg.Projects {
if cfg.Projects[i].Name == project {
proj = &cfg.Projects[i]
break
}
}
if proj == nil {
t.Fatalf("project %q not found", project)
}
agentType := proj.Agent.Type
bin, _ := findAgentBin(agentType)
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("skip: %s not in PATH", bin)
}
workDir := t.TempDir()
sessPath := filepath.Join(workDir, "sessions.json")
createAgent := func() core.Agent {
opts := make(map[string]any)
for k, v := range proj.Agent.Options {
opts[k] = v
}
opts["work_dir"] = workDir
agent, err := core.CreateAgent(agentType, opts)
if err != nil {
t.Skipf("skip: %v", err)
}
if ps, ok := agent.(core.ProviderSwitcher); ok {
var providers []core.ProviderConfig
for _, ref := range proj.Agent.ProviderRefs {
for _, gp := range cfg.Providers {
if gp.Name == ref {
providers = append(providers, configProviderToCore(gp))
break
}
}
}
if len(providers) > 0 {
ps.SetProviders(providers)
if pn, _ := opts["provider"].(string); pn != "" {
ps.SetActiveProvider(pn)
} else {
ps.SetActiveProvider(providers[0].Name)
}
}
}
return agent
}
// Phase 1: create session, send message, /new, send message
agent1 := createAgent()
mp1 := &mockPlatform{agent: agent1}
e1 := core.NewEngine("persist-test", agent1, []core.Platform{mp1}, sessPath, core.LangEnglish)
h1 := &e2eHelper{t: t, e: e1, mp: mp1, uk: sessionKey("persist-user")}
t.Log("[phase1] sending first message")
h1.sendAndWait("respond with exactly: PERSIST_S1", 90*time.Second)
t.Log("[phase1] /new persist-session-2")
h1.sendAndWait("/new persist-session-2", 10*time.Second)
t.Log("[phase1] message in session 2")
h1.sendAndWait("respond with exactly: PERSIST_S2", 90*time.Second)
t.Log("[phase1] /list")
list1 := h1.sendAndWait("/list", 10*time.Second)
c1 := countSessions(list1)
t.Logf("[phase1] %d sessions", c1)
// Simulate restart
agent1.Stop()
e1.Stop()
// Phase 2: new engine, same sessPath
agent2 := createAgent()
mp2 := &mockPlatform{agent: agent2}
e2 := core.NewEngine("persist-test", agent2, []core.Platform{mp2}, sessPath, core.LangEnglish)
defer func() { agent2.Stop(); e2.Stop() }()
h2 := &e2eHelper{t: t, e: e2, mp: mp2, uk: sessionKey("persist-user")}
t.Log("[phase2] /list after restart")
list2 := h2.sendAndWait("/list", 10*time.Second)
c2 := countSessions(list2)
if c2 < c1 {
t.Errorf("[phase2] sessions lost after restart: had %d, now %d\n%s", c1, c2, list2)
}
if !strings.Contains(list2, "persist-session-2") {
t.Errorf("[phase2] session name 'persist-session-2' lost after restart\n%s", list2)
}
t.Logf("[phase2] %d sessions survived restart, name preserved", c2)
t.Log("=== PERSISTENCE PASSED ===")
}
func TestE2E_Codex_SessionPersistence(t *testing.T) {
runE2E_SessionPersistence(t, "e2e-codex")
}
func TestE2E_ClaudeCode_SessionPersistence(t *testing.T) {
runE2E_SessionPersistence(t, "e2e-claudecode")
}