451 lines
14 KiB
Go
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")
|
|
}
|