337 lines
9.1 KiB
Go
337 lines
9.1 KiB
Go
//go:build e2e
|
|
|
|
// Package e2e provides end-to-end tests that exercise the full adapter lifecycle
|
|
// against real agent binaries (Claude Code, Amp, etc.).
|
|
//
|
|
// These tests cost money (real API subscriptions) and are gated behind the "e2e"
|
|
// build tag so they never run in CI. Run them manually:
|
|
//
|
|
// go test -tags e2e -v -timeout 10m ./internal/agentctl/server/adapter/e2e/
|
|
//
|
|
// Tests skip gracefully when an agent binary is not installed on PATH.
|
|
//
|
|
// Debug logging (set externally — read at package init time). Frames are
|
|
// written to per-session files raw-/normalized-{protocol}-{agentID}-{sessionID}.jsonl
|
|
// under KANDEV_DEBUG_LOG_DIR (default <KANDEV_HOME_DIR>/logs/acp, else ~/.kandev/logs/acp):
|
|
//
|
|
// KANDEV_DEBUG_AGENT_MESSAGES=true KANDEV_DEBUG_LOG_DIR=/tmp/e2e-debug ...
|
|
//
|
|
// OTel tracing:
|
|
//
|
|
// OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 ...
|
|
package e2e
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agentctl/server/adapter"
|
|
"github.com/kandev/kandev/internal/agentctl/server/config"
|
|
"github.com/kandev/kandev/internal/agentctl/server/process"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/pkg/agent"
|
|
)
|
|
|
|
// AgentSpec defines the configuration for an E2E agent test.
|
|
type AgentSpec struct {
|
|
// Name is a human-readable name for logging (e.g., "claude-code").
|
|
Name string
|
|
|
|
// Command is the full CLI command to run the agent.
|
|
// The first token must be a binary on PATH (or an absolute path).
|
|
// Tests skip if the binary is not found.
|
|
Command string
|
|
|
|
// Protocol is the agent protocol constant.
|
|
Protocol agent.Protocol
|
|
|
|
// DefaultPrompt is the prompt to send.
|
|
DefaultPrompt string
|
|
|
|
// Timeout is the maximum time to wait for the full test lifecycle.
|
|
// Defaults to 2 minutes if zero.
|
|
Timeout time.Duration
|
|
|
|
// AutoApprove enables auto-approval of permission requests.
|
|
AutoApprove bool
|
|
|
|
// ContinueCommand is the command for follow-up prompts (one-shot agents like Amp).
|
|
// When set, the adapter spawns a new subprocess per prompt.
|
|
ContinueCommand string
|
|
}
|
|
|
|
// TestResult holds collected events and metadata from a test run.
|
|
type TestResult struct {
|
|
Events []adapter.AgentEvent
|
|
SessionID string
|
|
OperationID string
|
|
Duration time.Duration
|
|
}
|
|
|
|
// RunAgent executes the full adapter lifecycle against an agent binary
|
|
// and returns collected events. Skips if the binary is not on PATH.
|
|
func RunAgent(t *testing.T, spec AgentSpec) *TestResult {
|
|
t.Helper()
|
|
requireBinary(t, spec.Command)
|
|
return runAgentLifecycle(t, spec, spec.Command)
|
|
}
|
|
|
|
// requireBinary checks that the first token of the command is a binary
|
|
// on PATH. Skips the test if not found.
|
|
func requireBinary(t *testing.T, command string) {
|
|
t.Helper()
|
|
fields := strings.Fields(command)
|
|
if len(fields) == 0 {
|
|
t.Fatal("empty command")
|
|
}
|
|
if _, err := exec.LookPath(fields[0]); err != nil {
|
|
t.Skipf("skipping: %s not found on PATH", fields[0])
|
|
}
|
|
}
|
|
|
|
// runAgentLifecycle implements the full lifecycle:
|
|
// workspace setup → process manager → Start → Initialize → NewSession → Prompt → collect events.
|
|
func runAgentLifecycle(t *testing.T, spec AgentSpec, command string) *TestResult {
|
|
t.Helper()
|
|
|
|
timeout := spec.Timeout
|
|
if timeout == 0 {
|
|
timeout = 2 * time.Minute
|
|
}
|
|
if envTimeout := os.Getenv("E2E_TIMEOUT"); envTimeout != "" {
|
|
if d, err := time.ParseDuration(envTimeout); err == nil {
|
|
timeout = d
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
// Set up workspace
|
|
workDir := setupWorkspace(t)
|
|
|
|
// Build config and create process manager
|
|
cfg := buildInstanceConfig(command, spec.Protocol, workDir, spec.AutoApprove, spec.ContinueCommand)
|
|
log := newTestLogger(t)
|
|
mgr := process.NewManager(cfg, log)
|
|
|
|
// Start the subprocess
|
|
start := time.Now()
|
|
if err := mgr.Start(ctx); err != nil {
|
|
t.Fatalf("failed to start agent: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer stopCancel()
|
|
_ = mgr.Stop(stopCtx)
|
|
})
|
|
|
|
// Initialize adapter
|
|
adpt := mgr.GetAdapter()
|
|
if adpt == nil {
|
|
t.Fatal("adapter is nil after Start")
|
|
}
|
|
if err := adpt.Initialize(ctx); err != nil {
|
|
t.Fatalf("failed to initialize adapter: %v", err)
|
|
}
|
|
|
|
// Create session
|
|
if _, err := adpt.NewSession(ctx, nil); err != nil {
|
|
t.Fatalf("failed to create session: %v", err)
|
|
}
|
|
|
|
// Collect events while prompting
|
|
events := collectEventsUntilPromptDone(ctx, t, mgr, adpt, spec.DefaultPrompt)
|
|
duration := time.Since(start)
|
|
|
|
return &TestResult{
|
|
Events: events,
|
|
SessionID: adpt.GetSessionID(),
|
|
OperationID: adpt.GetOperationID(),
|
|
Duration: duration,
|
|
}
|
|
}
|
|
|
|
// collectEventsUntilPromptDone drains the manager's updates channel while
|
|
// the adapter processes a prompt. Prompt() blocks until the turn completes.
|
|
func collectEventsUntilPromptDone(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
mgr *process.Manager,
|
|
adpt adapter.AgentAdapter,
|
|
prompt string,
|
|
) []adapter.AgentEvent {
|
|
t.Helper()
|
|
|
|
var events []adapter.AgentEvent
|
|
var mu sync.Mutex
|
|
done := make(chan struct{})
|
|
|
|
// Start event collector goroutine
|
|
go func() {
|
|
defer close(done)
|
|
ch := mgr.GetUpdates()
|
|
for {
|
|
select {
|
|
case ev, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
events = append(events, ev)
|
|
mu.Unlock()
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Send prompt (blocks until turn completes or context cancels)
|
|
if err := adpt.Prompt(ctx, prompt, nil); err != nil {
|
|
t.Fatalf("prompt failed: %v", err)
|
|
}
|
|
|
|
// Grace period for remaining events to propagate through the channel
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
result := make([]adapter.AgentEvent, len(events))
|
|
copy(result, events)
|
|
return result
|
|
}
|
|
|
|
// setupWorkspace creates a temp directory with git init for agents that require a repo.
|
|
func setupWorkspace(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
if envDir := os.Getenv("E2E_WORKDIR"); envDir != "" {
|
|
return envDir
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
cmd := exec.Command("git", "init")
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git init failed in %s: %v\n%s", dir, err, out)
|
|
}
|
|
|
|
// Create a minimal file so the repo isn't completely empty
|
|
if err := os.WriteFile(dir+"/README.md", []byte("# E2E Test Workspace\n"), 0644); err != nil {
|
|
t.Fatalf("failed to create README.md: %v", err)
|
|
}
|
|
|
|
cmd = exec.Command("git", "add", ".")
|
|
cmd.Dir = dir
|
|
_ = cmd.Run()
|
|
|
|
cmd = exec.Command("git", "commit", "-m", "init", "--allow-empty")
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com",
|
|
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com",
|
|
)
|
|
_ = cmd.Run()
|
|
|
|
return dir
|
|
}
|
|
|
|
// buildInstanceConfig creates a config.InstanceConfig from the test parameters.
|
|
func buildInstanceConfig(command string, protocol agent.Protocol, workDir string, autoApprove bool, continueCommand string) *config.InstanceConfig {
|
|
args := config.ParseCommand(command)
|
|
env := config.CollectAgentEnv(nil)
|
|
|
|
return &config.InstanceConfig{
|
|
Protocol: protocol,
|
|
AgentCommand: command,
|
|
AgentArgs: args,
|
|
WorkDir: workDir,
|
|
AgentEnv: env,
|
|
AutoApprovePermissions: autoApprove,
|
|
ApprovalPolicy: "never",
|
|
ShellEnabled: false,
|
|
LogLevel: "debug",
|
|
LogFormat: "console",
|
|
AgentType: string(protocol),
|
|
ContinueCommand: continueCommand,
|
|
}
|
|
}
|
|
|
|
// newTestLogger creates a logger for e2e tests. Uses debug level so adapter
|
|
// internals are visible in test output via -v.
|
|
func newTestLogger(t *testing.T) *logger.Logger {
|
|
t.Helper()
|
|
level := "info"
|
|
if testing.Verbose() {
|
|
level = "debug"
|
|
}
|
|
log, err := logger.NewLogger(logger.LoggingConfig{
|
|
Level: level,
|
|
Format: "console",
|
|
OutputPath: "stderr",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create logger: %v", err)
|
|
}
|
|
return log
|
|
}
|
|
|
|
// buildMockAgent compiles the mock-agent binary and returns its path.
|
|
func buildMockAgent(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
// Find the backend root relative to this test file
|
|
backendRoot := findBackendRoot(t)
|
|
|
|
binary := t.TempDir() + "/mock-agent"
|
|
cmd := exec.Command("go", "build", "-o", binary, "./cmd/mock-agent")
|
|
cmd.Dir = backendRoot
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("failed to build mock-agent: %v\n%s", err, out)
|
|
}
|
|
return binary
|
|
}
|
|
|
|
// findBackendRoot walks up from CWD to find apps/backend.
|
|
func findBackendRoot(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
// Try common relative paths from the e2e test directory
|
|
candidates := []string{
|
|
"../../../../../..", // from internal/agentctl/server/adapter/e2e/ back to apps/backend
|
|
}
|
|
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("failed to get cwd: %v", err)
|
|
}
|
|
|
|
for _, rel := range candidates {
|
|
abs := cwd + "/" + rel
|
|
if _, err := os.Stat(abs + "/cmd/mock-agent"); err == nil {
|
|
return abs
|
|
}
|
|
}
|
|
|
|
// Fallback: walk up from cwd looking for cmd/mock-agent
|
|
dir := cwd
|
|
for i := 0; i < 10; i++ {
|
|
if _, err := os.Stat(dir + "/cmd/mock-agent"); err == nil {
|
|
return dir
|
|
}
|
|
parent := dir[:strings.LastIndex(dir, "/")]
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
|
|
t.Fatalf("could not find backend root (cmd/mock-agent) from %s", cwd)
|
|
return ""
|
|
}
|