493 lines
14 KiB
Go
493 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/kandev/kandev/internal/agentctl/server/config"
|
|
"github.com/kandev/kandev/internal/agentctl/server/process"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
ws "github.com/kandev/kandev/pkg/websocket"
|
|
)
|
|
|
|
func newTestLogger() *logger.Logger {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{
|
|
Level: "error",
|
|
Format: "json",
|
|
})
|
|
return log
|
|
}
|
|
|
|
// newTestServer creates a minimal Server with a process.Manager (no adapter).
|
|
// Adapter is nil so all handlers that need it return "agent not running".
|
|
func newTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
log := newTestLogger()
|
|
cfg := &config.InstanceConfig{
|
|
Port: 0,
|
|
WorkDir: "/tmp/test",
|
|
}
|
|
procMgr := process.NewManager(cfg, log)
|
|
return NewServer(cfg, procMgr, nil, nil, log)
|
|
}
|
|
|
|
// dialTestWS connects a WebSocket client to the test server's /api/v1/agent/stream endpoint.
|
|
func dialTestWS(t *testing.T, server *httptest.Server) *websocket.Conn {
|
|
t.Helper()
|
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent/stream"
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to dial WebSocket: %v", err)
|
|
}
|
|
return conn
|
|
}
|
|
|
|
// sendWSRequest sends a ws.Message request and reads the response.
|
|
func sendWSRequest(t *testing.T, conn *websocket.Conn, action string, payload interface{}) *ws.Message {
|
|
t.Helper()
|
|
msg, err := ws.NewRequest("test-req-id", action, payload)
|
|
if err != nil {
|
|
t.Fatalf("failed to create request: %v", err)
|
|
}
|
|
data, err := json.Marshal(msg)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal request: %v", err)
|
|
}
|
|
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
|
t.Fatalf("failed to write request: %v", err)
|
|
}
|
|
|
|
// Read response with timeout
|
|
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
_, respData, err := conn.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("failed to read response: %v", err)
|
|
}
|
|
|
|
var resp ws.Message
|
|
if err := json.Unmarshal(respData, &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
return &resp
|
|
}
|
|
|
|
// --- Tests for handleAgentStreamRequest dispatcher ---
|
|
|
|
func TestHandleAgentStreamRequest_UnknownAction(t *testing.T) {
|
|
s := newTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
msg, _ := ws.NewRequest("req-1", "unknown.action", nil)
|
|
resp := s.handleAgentStreamRequest(ctx, msg)
|
|
|
|
if resp == nil {
|
|
t.Fatal("expected response")
|
|
} else if resp.Type != ws.MessageTypeError {
|
|
t.Errorf("expected error type, got %q", resp.Type)
|
|
}
|
|
|
|
var errPayload ws.ErrorPayload
|
|
if err := resp.ParsePayload(&errPayload); err != nil {
|
|
t.Fatalf("failed to parse error payload: %v", err)
|
|
}
|
|
if errPayload.Code != ws.ErrorCodeUnknownAction {
|
|
t.Errorf("expected UNKNOWN_ACTION code, got %q", errPayload.Code)
|
|
}
|
|
if !strings.Contains(errPayload.Message, "unknown.action") {
|
|
t.Errorf("expected error message to contain action name, got %q", errPayload.Message)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentStreamRequest_DispatchesCorrectActions(t *testing.T) {
|
|
s := newTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
// All these should dispatch to their handlers (returning "agent not running" since no adapter)
|
|
actions := []string{
|
|
"agent.initialize",
|
|
"agent.session.new",
|
|
"agent.session.load",
|
|
"agent.prompt",
|
|
"agent.cancel",
|
|
"agent.permissions.respond",
|
|
"agent.stderr",
|
|
}
|
|
|
|
for _, action := range actions {
|
|
t.Run(action, func(t *testing.T) {
|
|
msg, _ := ws.NewRequest("req-"+action, action, map[string]string{})
|
|
resp := s.handleAgentStreamRequest(ctx, msg)
|
|
|
|
if resp == nil {
|
|
t.Fatal("expected response")
|
|
} else if resp.ID != "req-"+action {
|
|
t.Errorf("expected response ID 'req-%s', got %q", action, resp.ID)
|
|
}
|
|
// stderr doesn't need an adapter, so it should succeed
|
|
if action == "agent.stderr" {
|
|
if resp.Type != ws.MessageTypeResponse {
|
|
t.Errorf("expected response type for stderr, got %q", resp.Type)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Tests for individual WS handlers with no adapter (agent not running) ---
|
|
|
|
func TestHandleWSInitialize_NoAdapter(t *testing.T) {
|
|
s := newTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
msg, _ := ws.NewRequest("req-1", "agent.initialize", map[string]string{
|
|
"client_name": "test",
|
|
"client_version": "1.0.0",
|
|
})
|
|
resp := s.handleWSInitialize(ctx, msg)
|
|
|
|
if resp.Type != ws.MessageTypeError {
|
|
t.Errorf("expected error type, got %q", resp.Type)
|
|
}
|
|
var errPayload ws.ErrorPayload
|
|
if err := resp.ParsePayload(&errPayload); err != nil {
|
|
t.Fatalf("failed to parse error: %v", err)
|
|
}
|
|
if !strings.Contains(errPayload.Message, "agent not running") {
|
|
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
|
|
}
|
|
}
|
|
|
|
func TestHandleWSNewSession_NoAdapter(t *testing.T) {
|
|
s := newTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
msg, _ := ws.NewRequest("req-1", "agent.session.new", map[string]string{
|
|
"cwd": "/workspace",
|
|
})
|
|
resp := s.handleWSNewSession(ctx, msg)
|
|
|
|
if resp.Type != ws.MessageTypeError {
|
|
t.Errorf("expected error type, got %q", resp.Type)
|
|
}
|
|
var errPayload ws.ErrorPayload
|
|
if err := resp.ParsePayload(&errPayload); err != nil {
|
|
t.Fatalf("failed to parse error: %v", err)
|
|
}
|
|
if !strings.Contains(errPayload.Message, "agent not running") {
|
|
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
|
|
}
|
|
}
|
|
|
|
func TestHandleWSLoadSession_NoAdapter(t *testing.T) {
|
|
s := newTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
msg, _ := ws.NewRequest("req-1", "agent.session.load", map[string]string{
|
|
"session_id": "sess-123",
|
|
})
|
|
resp := s.handleWSLoadSession(ctx, msg)
|
|
|
|
if resp.Type != ws.MessageTypeError {
|
|
t.Errorf("expected error type, got %q", resp.Type)
|
|
}
|
|
var errPayload ws.ErrorPayload
|
|
if err := resp.ParsePayload(&errPayload); err != nil {
|
|
t.Fatalf("failed to parse error: %v", err)
|
|
}
|
|
if !strings.Contains(errPayload.Message, "agent not running") {
|
|
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
|
|
}
|
|
}
|
|
|
|
func TestHandleWSLoadSession_MissingSessionID(t *testing.T) {
|
|
s := newTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
msg, _ := ws.NewRequest("req-1", "agent.session.load", map[string]string{})
|
|
resp := s.handleWSLoadSession(ctx, msg)
|
|
|
|
if resp.Type != ws.MessageTypeError {
|
|
t.Errorf("expected error type, got %q", resp.Type)
|
|
}
|
|
var errPayload ws.ErrorPayload
|
|
if err := resp.ParsePayload(&errPayload); err != nil {
|
|
t.Fatalf("failed to parse error: %v", err)
|
|
}
|
|
if errPayload.Code != ws.ErrorCodeBadRequest {
|
|
t.Errorf("expected BAD_REQUEST code, got %q", errPayload.Code)
|
|
}
|
|
if !strings.Contains(errPayload.Message, "session_id is required") {
|
|
t.Errorf("expected 'session_id is required', got %q", errPayload.Message)
|
|
}
|
|
}
|
|
|
|
func TestHandleWSPrompt_NoAdapter(t *testing.T) {
|
|
s := newTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
msg, _ := ws.NewRequest("req-1", "agent.prompt", map[string]string{
|
|
"text": "hello",
|
|
})
|
|
resp := s.handleWSPrompt(ctx, msg)
|
|
|
|
if resp.Type != ws.MessageTypeError {
|
|
t.Errorf("expected error type, got %q", resp.Type)
|
|
}
|
|
var errPayload ws.ErrorPayload
|
|
if err := resp.ParsePayload(&errPayload); err != nil {
|
|
t.Fatalf("failed to parse error: %v", err)
|
|
}
|
|
if !strings.Contains(errPayload.Message, "agent not running") {
|
|
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
|
|
}
|
|
}
|
|
|
|
func TestHandleWSCancel_NoAdapter(t *testing.T) {
|
|
s := newTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
msg, _ := ws.NewRequest("req-1", "agent.cancel", nil)
|
|
resp := s.handleWSCancel(ctx, msg)
|
|
|
|
if resp.Type != ws.MessageTypeError {
|
|
t.Errorf("expected error type, got %q", resp.Type)
|
|
}
|
|
var errPayload ws.ErrorPayload
|
|
if err := resp.ParsePayload(&errPayload); err != nil {
|
|
t.Fatalf("failed to parse error: %v", err)
|
|
}
|
|
if !strings.Contains(errPayload.Message, "agent not running") {
|
|
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
|
|
}
|
|
}
|
|
|
|
func TestHandleWSStderr_Empty(t *testing.T) {
|
|
s := newTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
msg, _ := ws.NewRequest("req-1", "agent.stderr", nil)
|
|
resp := s.handleWSStderr(ctx, msg)
|
|
|
|
if resp.Type != ws.MessageTypeResponse {
|
|
t.Errorf("expected response type, got %q", resp.Type)
|
|
}
|
|
|
|
var result AgentStderrResponse
|
|
if err := resp.ParsePayload(&result); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
if len(result.Lines) != 0 {
|
|
t.Errorf("expected empty lines, got %d", len(result.Lines))
|
|
}
|
|
}
|
|
|
|
func TestHandleWSPermissionRespond_BadPayload(t *testing.T) {
|
|
s := newTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
// pending_id is required but since it's not gin binding, ParsePayload will succeed
|
|
// with empty values. The actual error comes from RespondToPermission not finding it.
|
|
msg, _ := ws.NewRequest("req-1", "agent.permissions.respond", map[string]string{
|
|
"pending_id": "nonexistent",
|
|
"option_id": "allow",
|
|
})
|
|
resp := s.handleWSPermissionRespond(ctx, msg)
|
|
|
|
if resp.Type != ws.MessageTypeError {
|
|
t.Errorf("expected error type, got %q", resp.Type)
|
|
}
|
|
var errPayload ws.ErrorPayload
|
|
if err := resp.ParsePayload(&errPayload); err != nil {
|
|
t.Fatalf("failed to parse error: %v", err)
|
|
}
|
|
if errPayload.Code != ws.ErrorCodeNotFound {
|
|
t.Errorf("expected NOT_FOUND code, got %q", errPayload.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleWSInitialize_BadPayload(t *testing.T) {
|
|
s := newTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
// Send invalid payload (non-JSON raw message)
|
|
msg := &ws.Message{
|
|
ID: "req-1",
|
|
Type: ws.MessageTypeRequest,
|
|
Action: "agent.initialize",
|
|
Payload: json.RawMessage(`invalid json`),
|
|
}
|
|
resp := s.handleWSInitialize(ctx, msg)
|
|
|
|
if resp.Type != ws.MessageTypeError {
|
|
t.Errorf("expected error type, got %q", resp.Type)
|
|
}
|
|
var errPayload ws.ErrorPayload
|
|
if err := resp.ParsePayload(&errPayload); err != nil {
|
|
t.Fatalf("failed to parse error: %v", err)
|
|
}
|
|
if errPayload.Code != ws.ErrorCodeBadRequest {
|
|
t.Errorf("expected BAD_REQUEST code, got %q", errPayload.Code)
|
|
}
|
|
}
|
|
|
|
// --- WebSocket integration tests ---
|
|
|
|
func TestAgentStreamWS_RequestResponseFlow(t *testing.T) {
|
|
s := newTestServer(t)
|
|
server := httptest.NewServer(s.router)
|
|
defer server.Close()
|
|
|
|
conn := dialTestWS(t, server)
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
// Send an stderr request (doesn't need adapter)
|
|
resp := sendWSRequest(t, conn, "agent.stderr", nil)
|
|
if resp.Type != ws.MessageTypeResponse {
|
|
t.Errorf("expected response type, got %q", resp.Type)
|
|
}
|
|
if resp.ID != "test-req-id" {
|
|
t.Errorf("expected response ID 'test-req-id', got %q", resp.ID)
|
|
}
|
|
}
|
|
|
|
func TestAgentStreamWS_UnknownActionReturnsError(t *testing.T) {
|
|
s := newTestServer(t)
|
|
server := httptest.NewServer(s.router)
|
|
defer server.Close()
|
|
|
|
conn := dialTestWS(t, server)
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
resp := sendWSRequest(t, conn, "nonexistent.action", nil)
|
|
if resp.Type != ws.MessageTypeError {
|
|
t.Errorf("expected error type, got %q", resp.Type)
|
|
}
|
|
var errPayload ws.ErrorPayload
|
|
if err := resp.ParsePayload(&errPayload); err != nil {
|
|
t.Fatalf("failed to parse error: %v", err)
|
|
}
|
|
if errPayload.Code != ws.ErrorCodeUnknownAction {
|
|
t.Errorf("expected UNKNOWN_ACTION, got %q", errPayload.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentStreamWS_AgentNotRunningError(t *testing.T) {
|
|
s := newTestServer(t)
|
|
server := httptest.NewServer(s.router)
|
|
defer server.Close()
|
|
|
|
conn := dialTestWS(t, server)
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
resp := sendWSRequest(t, conn, "agent.initialize", map[string]string{
|
|
"client_name": "test",
|
|
"client_version": "1.0.0",
|
|
})
|
|
if resp.Type != ws.MessageTypeError {
|
|
t.Errorf("expected error type, got %q", resp.Type)
|
|
}
|
|
var errPayload ws.ErrorPayload
|
|
if err := resp.ParsePayload(&errPayload); err != nil {
|
|
t.Fatalf("failed to parse error: %v", err)
|
|
}
|
|
if !strings.Contains(errPayload.Message, "agent not running") {
|
|
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
|
|
}
|
|
}
|
|
|
|
func TestAgentStreamWS_MultipleRequests(t *testing.T) {
|
|
s := newTestServer(t)
|
|
server := httptest.NewServer(s.router)
|
|
defer server.Close()
|
|
|
|
conn := dialTestWS(t, server)
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
// Send multiple requests sequentially
|
|
actions := []string{"agent.stderr", "agent.stderr", "agent.cancel"}
|
|
for i, action := range actions {
|
|
msg, _ := ws.NewRequest("req-"+string(rune('a'+i)), action, nil)
|
|
data, _ := json.Marshal(msg)
|
|
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
|
t.Fatalf("failed to write request %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Read all responses
|
|
for i := 0; i < len(actions); i++ {
|
|
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
_, respData, err := conn.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("failed to read response %d: %v", i, err)
|
|
}
|
|
var resp ws.Message
|
|
if err := json.Unmarshal(respData, &resp); err != nil {
|
|
t.Fatalf("failed to parse response %d: %v", i, err)
|
|
}
|
|
// All should have some response (either success or error)
|
|
if resp.Type != ws.MessageTypeResponse && resp.Type != ws.MessageTypeError {
|
|
t.Errorf("response %d: expected response or error type, got %q", i, resp.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAgentStreamWS_ResponsePreservesMessageID(t *testing.T) {
|
|
s := newTestServer(t)
|
|
server := httptest.NewServer(s.router)
|
|
defer server.Close()
|
|
|
|
conn := dialTestWS(t, server)
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
requestID := "unique-req-id-12345"
|
|
msg, _ := ws.NewRequest(requestID, "agent.stderr", nil)
|
|
data, _ := json.Marshal(msg)
|
|
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
|
t.Fatalf("failed to write: %v", err)
|
|
}
|
|
|
|
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
_, respData, err := conn.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("failed to read: %v", err)
|
|
}
|
|
|
|
var resp ws.Message
|
|
if err := json.Unmarshal(respData, &resp); err != nil {
|
|
t.Fatalf("failed to parse: %v", err)
|
|
}
|
|
|
|
if resp.ID != requestID {
|
|
t.Errorf("expected response ID %q, got %q", requestID, resp.ID)
|
|
}
|
|
if resp.Action != "agent.stderr" {
|
|
t.Errorf("expected action 'agent.stderr', got %q", resp.Action)
|
|
}
|
|
}
|
|
|
|
func TestAgentStreamWS_MalformedMessage(t *testing.T) {
|
|
s := newTestServer(t)
|
|
server := httptest.NewServer(s.router)
|
|
defer server.Close()
|
|
|
|
conn := dialTestWS(t, server)
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
// Send malformed JSON - should not crash the server
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(`not json`)); err != nil {
|
|
t.Fatalf("failed to write: %v", err)
|
|
}
|
|
|
|
// Server should still be alive - send a valid request
|
|
time.Sleep(50 * time.Millisecond)
|
|
resp := sendWSRequest(t, conn, "agent.stderr", nil)
|
|
if resp.Type != ws.MessageTypeResponse {
|
|
t.Errorf("expected response after malformed message, got %q", resp.Type)
|
|
}
|
|
}
|