Files
cc-connect/tests/integration/multi_workspace_shared_test.go
2026-06-02 23:14:41 +08:00

361 lines
11 KiB
Go

//go:build integration
package integration
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
"github.com/stretchr/testify/require"
)
const integrationSharedAgentName = "integration-shared-routing-agent"
var (
registerIntegrationSharedAgentOnce sync.Once
integrationMessageSeq uint64
)
type integrationRoutingAgent struct {
workDir string
}
func (a *integrationRoutingAgent) Name() string { return integrationSharedAgentName }
func (a *integrationRoutingAgent) StartSession(_ context.Context, sessionID string) (core.AgentSession, error) {
return newIntegrationRoutingSession(sessionID, a.workDir), nil
}
func (a *integrationRoutingAgent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
return nil, nil
}
func (a *integrationRoutingAgent) Stop() error { return nil }
type integrationRoutingSession struct {
mu sync.RWMutex
sessionID string
workDir string
alive bool
events chan core.Event
}
func newIntegrationRoutingSession(sessionID, workDir string) *integrationRoutingSession {
return &integrationRoutingSession{
sessionID: sessionID,
workDir: workDir,
alive: true,
events: make(chan core.Event, 8),
}
}
func (s *integrationRoutingSession) Send(prompt string, _ []core.ImageAttachment, _ []core.FileAttachment) error {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.alive {
return io.ErrClosedPipe
}
s.events <- core.Event{
Type: core.EventResult,
Content: fmt.Sprintf("workspace=%s prompt=%s", s.workDir, prompt),
Done: true,
}
return nil
}
func (s *integrationRoutingSession) RespondPermission(string, core.PermissionResult) error {
return nil
}
func (s *integrationRoutingSession) Events() <-chan core.Event {
return s.events
}
func (s *integrationRoutingSession) CurrentSessionID() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.sessionID
}
func (s *integrationRoutingSession) Alive() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.alive
}
func (s *integrationRoutingSession) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.alive {
s.alive = false
close(s.events)
}
return nil
}
type integrationPlatform struct {
mu sync.Mutex
name string
channelNames map[string]string
handler core.MessageHandler
outputs []string
}
func newIntegrationPlatform(name string, channelNames map[string]string) *integrationPlatform {
return &integrationPlatform{
name: name,
channelNames: channelNames,
outputs: make([]string, 0),
}
}
func (p *integrationPlatform) Name() string { return p.name }
func (p *integrationPlatform) Start(handler core.MessageHandler) error {
p.mu.Lock()
defer p.mu.Unlock()
p.handler = handler
return nil
}
func (p *integrationPlatform) Reply(_ context.Context, _ any, content string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.outputs = append(p.outputs, content)
return nil
}
func (p *integrationPlatform) Send(_ context.Context, _ any, content string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.outputs = append(p.outputs, content)
return nil
}
func (p *integrationPlatform) Stop() error { return nil }
func (p *integrationPlatform) ResolveChannelName(channelID string) (string, error) {
if name, ok := p.channelNames[channelID]; ok {
return name, nil
}
return "", fmt.Errorf("unknown channel %q", channelID)
}
func (p *integrationPlatform) Emit(msg *core.Message) {
p.mu.Lock()
handler := p.handler
p.mu.Unlock()
if handler == nil {
panic("integrationPlatform.Emit called before Start")
}
handler(p, msg)
}
func (p *integrationPlatform) ClearOutputs() {
p.mu.Lock()
defer p.mu.Unlock()
p.outputs = p.outputs[:0]
}
func (p *integrationPlatform) Outputs() []string {
p.mu.Lock()
defer p.mu.Unlock()
cp := make([]string, len(p.outputs))
copy(cp, p.outputs)
return cp
}
func (p *integrationPlatform) WaitForOutputContaining(t *testing.T, needle string) string {
t.Helper()
var matched string
require.Eventually(t, func() bool {
for _, output := range p.Outputs() {
if strings.Contains(output, needle) {
matched = output
return true
}
}
return false
}, 3*time.Second, 20*time.Millisecond)
return matched
}
func registerIntegrationSharedAgent() {
registerIntegrationSharedAgentOnce.Do(func() {
core.RegisterAgent(integrationSharedAgentName, func(opts map[string]any) (core.Agent, error) {
workDir, _ := opts["work_dir"].(string)
return &integrationRoutingAgent{workDir: workDir}, nil
})
})
}
func newIntegrationEngine(t *testing.T, projectName string, platform *integrationPlatform, baseDir, bindingStore, sessionStore string) *core.Engine {
t.Helper()
registerIntegrationSharedAgent()
engine := core.NewEngine(
projectName,
&integrationRoutingAgent{workDir: baseDir},
[]core.Platform{platform},
sessionStore,
core.LangEnglish,
)
engine.SetAdminFrom("admin")
engine.SetMultiWorkspace(baseDir, bindingStore)
require.NoError(t, engine.Start())
t.Cleanup(func() {
_ = engine.Stop()
})
return engine
}
func integrationMessage(platformName, channelID, userID, content string) *core.Message {
seq := atomic.AddUint64(&integrationMessageSeq, 1)
chatName := channelID
return &core.Message{
SessionKey: fmt.Sprintf("%s:%s:%s", platformName, channelID, userID),
Platform: platformName,
MessageID: fmt.Sprintf("msg-%d", seq),
UserID: userID,
UserName: userID,
ChatName: chatName,
Content: content,
ReplyCtx: fmt.Sprintf("reply-%d", seq),
}
}
func TestIntegration_SharedWorkspaceBindingLiveSyncAcrossProjects(t *testing.T) {
baseDir := t.TempDir()
bindingStore := filepath.Join(t.TempDir(), "workspace_bindings.json")
channelID := "shared-channel"
channelNames := map[string]string{channelID: "shared-channel"}
platformName := "shared-platform"
sharedDir := filepath.Join(baseDir, "shared-workspace")
require.NoError(t, os.MkdirAll(sharedDir, 0o755))
platformA := newIntegrationPlatform(platformName, channelNames)
engineA := newIntegrationEngine(t, "project-a", platformA, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-a-sessions.json"))
_ = engineA
platformB := newIntegrationPlatform(platformName, channelNames)
engineB := newIntegrationEngine(t, "project-b", platformB, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-b-sessions.json"))
_ = engineB
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "/workspace shared bind shared-workspace"))
platformA.WaitForOutputContaining(t, "Shared workspace bound")
platformA.ClearOutputs()
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "hello from project a"))
platformA.WaitForOutputContaining(t, sharedDir)
platformB.ClearOutputs()
platformB.Emit(integrationMessage(platformB.Name(), channelID, "user-b", "hello from project b"))
platformB.WaitForOutputContaining(t, sharedDir)
platformA.ClearOutputs()
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "/workspace shared unbind"))
platformA.WaitForOutputContaining(t, "Shared workspace unbound")
platformB.ClearOutputs()
platformB.Emit(integrationMessage(platformB.Name(), channelID, "user-b", "after shared unbind"))
platformB.WaitForOutputContaining(t, "No workspace found for this channel")
}
func TestIntegration_ProjectWorkspaceOverridesSharedAcrossProjects(t *testing.T) {
baseDir := t.TempDir()
bindingStore := filepath.Join(t.TempDir(), "workspace_bindings.json")
channelID := "override-channel"
channelNames := map[string]string{channelID: "override-channel"}
platformName := "shared-platform"
sharedDir := filepath.Join(baseDir, "shared-workspace")
projectBDir := filepath.Join(baseDir, "project-b-workspace")
require.NoError(t, os.MkdirAll(sharedDir, 0o755))
require.NoError(t, os.MkdirAll(projectBDir, 0o755))
platformA := newIntegrationPlatform(platformName, channelNames)
engineA := newIntegrationEngine(t, "project-a", platformA, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-a-sessions.json"))
_ = engineA
platformB := newIntegrationPlatform(platformName, channelNames)
engineB := newIntegrationEngine(t, "project-b", platformB, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-b-sessions.json"))
_ = engineB
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "/workspace shared bind shared-workspace"))
platformA.WaitForOutputContaining(t, "Shared workspace bound")
platformB.ClearOutputs()
platformB.Emit(integrationMessage(platformB.Name(), channelID, "user-b", "/workspace bind project-b-workspace"))
platformB.WaitForOutputContaining(t, "Workspace bound")
platformA.ClearOutputs()
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "route project a"))
platformA.WaitForOutputContaining(t, sharedDir)
platformB.ClearOutputs()
platformB.Emit(integrationMessage(platformB.Name(), channelID, "user-b", "route project b"))
platformB.WaitForOutputContaining(t, projectBDir)
}
func TestIntegration_ProjectWorkspaceRouteUsesAbsolutePath(t *testing.T) {
baseDir := t.TempDir()
bindingStore := filepath.Join(t.TempDir(), "workspace_bindings.json")
channelID := "route-channel"
channelNames := map[string]string{channelID: "route-channel"}
routedDir := filepath.Join(t.TempDir(), "routed workspace")
require.NoError(t, os.MkdirAll(routedDir, 0o755))
platform := newIntegrationPlatform("proj-route-platform", channelNames)
engine := newIntegrationEngine(t, "project-route", platform, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-route-sessions.json"))
_ = engine
platform.Emit(integrationMessage(platform.Name(), channelID, "user-route", "/workspace route "+routedDir))
platform.WaitForOutputContaining(t, "Workspace routed")
platform.ClearOutputs()
platform.Emit(integrationMessage(platform.Name(), channelID, "user-route", "hello routed workspace"))
platform.WaitForOutputContaining(t, routedDir)
}
func TestIntegration_SharedWorkspaceRouteLiveSyncAcrossProjects(t *testing.T) {
baseDir := t.TempDir()
bindingStore := filepath.Join(t.TempDir(), "workspace_bindings.json")
channelID := "shared-route-channel"
channelNames := map[string]string{channelID: "shared-route-channel"}
platformName := "shared-platform"
routedDir := filepath.Join(t.TempDir(), "shared routed workspace")
require.NoError(t, os.MkdirAll(routedDir, 0o755))
platformA := newIntegrationPlatform(platformName, channelNames)
engineA := newIntegrationEngine(t, "project-shared-route-a", platformA, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-shared-route-a-sessions.json"))
_ = engineA
platformB := newIntegrationPlatform(platformName, channelNames)
engineB := newIntegrationEngine(t, "project-shared-route-b", platformB, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-shared-route-b-sessions.json"))
_ = engineB
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "/workspace shared route "+routedDir))
platformA.WaitForOutputContaining(t, "Shared workspace routed")
platformA.ClearOutputs()
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "hello from shared route a"))
platformA.WaitForOutputContaining(t, routedDir)
platformB.ClearOutputs()
platformB.Emit(integrationMessage(platformB.Name(), channelID, "user-b", "hello from shared route b"))
platformB.WaitForOutputContaining(t, routedDir)
}