test(agent): increase handler coverage (#2546)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -5,6 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
@@ -105,6 +111,247 @@ func (m *mockSessionReader) GetSessionBaseBranch(_ context.Context, sessionID st
|
||||
return m.baseBranches[sessionID]
|
||||
}
|
||||
|
||||
func TestGitMutationHandlersForwardRequestsAndReturnResults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
request any
|
||||
invoke func(*GitHandlers, context.Context, *ws.Message) (*ws.Message, error)
|
||||
assertBody func(*testing.T, map[string]any)
|
||||
}{
|
||||
{name: "pull", path: "/api/v1/git/pull", request: GitPullRequest{SessionID: "s", Rebase: true, Repo: "repo"}, invoke: (*GitHandlers).wsPull, assertBody: expectGitBody("rebase", true)},
|
||||
{name: "push", path: "/api/v1/git/push", request: GitPushRequest{SessionID: "s", Force: true, SetUpstream: true, Repo: "repo"}, invoke: (*GitHandlers).wsPush, assertBody: expectGitBody("force", true)},
|
||||
{name: "commit", path: "/api/v1/git/commit", request: GitCommitRequest{SessionID: "s", Message: "message", StageAll: true, Repo: "repo"}, invoke: (*GitHandlers).wsCommit, assertBody: expectGitBody("message", "message")},
|
||||
{name: "rebase", path: "/api/v1/git/rebase", request: GitRebaseRequest{SessionID: "s", BaseBranch: "main", Repo: "repo"}, invoke: (*GitHandlers).wsRebase, assertBody: expectGitBody("base_branch", "main")},
|
||||
{name: "merge", path: "/api/v1/git/merge", request: GitMergeRequest{SessionID: "s", BaseBranch: "main", Repo: "repo"}, invoke: (*GitHandlers).wsMerge, assertBody: expectGitBody("base_branch", "main")},
|
||||
{name: "abort", path: "/api/v1/git/abort", request: GitAbortRequest{SessionID: "s", Operation: "rebase", Repo: "repo"}, invoke: (*GitHandlers).wsAbort, assertBody: expectGitBody("operation", "rebase")},
|
||||
{name: "reset defaults mixed", path: "/api/v1/git/reset", request: GitResetRequest{SessionID: "s", CommitSHA: "abc", Repo: "repo"}, invoke: (*GitHandlers).wsReset, assertBody: expectGitBody("mode", "mixed")},
|
||||
{name: "stage", path: "/api/v1/git/stage", request: GitStageRequest{SessionID: "s", Paths: []string{"a.go"}, Repo: "repo"}, invoke: (*GitHandlers).wsStage, assertBody: expectGitBody("repo", "repo")},
|
||||
{name: "unstage", path: "/api/v1/git/unstage", request: GitUnstageRequest{SessionID: "s", Paths: []string{"a.go"}, Repo: "repo"}, invoke: (*GitHandlers).wsUnstage, assertBody: expectGitBody("repo", "repo")},
|
||||
{name: "discard", path: "/api/v1/git/discard", request: GitDiscardRequest{SessionID: "s", Paths: []string{"a.go"}, Repo: "repo"}, invoke: (*GitHandlers).wsDiscard, assertBody: expectGitBody("repo", "repo")},
|
||||
{name: "revert", path: "/api/v1/git/revert-commit", request: GitRevertCommitRequest{SessionID: "s", CommitSHA: "abc", Repo: "repo"}, invoke: (*GitHandlers).wsRevertCommit, assertBody: expectGitBody("commit_sha", "abc")},
|
||||
{name: "commit diff", path: "/api/v1/git/commit/abc", request: GitShowCommitRequest{SessionID: "s", CommitSHA: "abc", Repo: "repo"}, invoke: (*GitHandlers).wsCommitDiff, assertBody: func(*testing.T, map[string]any) {}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var body map[string]any
|
||||
h, server := gitHandlerServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != tt.path {
|
||||
t.Errorf("path = %s, want %s", r.URL.Path, tt.path)
|
||||
}
|
||||
if tt.name != "commit diff" {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
}
|
||||
if tt.name == "commit diff" {
|
||||
_, _ = w.Write([]byte(`{"success":true,"commit_sha":"abc","diff":"patch"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true,"operation":"ok","output":"done"}`))
|
||||
})
|
||||
defer server.Close()
|
||||
msg, err := ws.NewRequest("id", "action", tt.request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := tt.invoke(h, context.Background(), msg)
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
if response == nil || !strings.Contains(string(response.Payload), `"success":true`) {
|
||||
t.Fatalf("response = %#v", response)
|
||||
}
|
||||
tt.assertBody(t, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitRenameBranchCallsCallbackOnlyOnSuccess(t *testing.T) {
|
||||
for _, success := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("success_%t", success), func(t *testing.T) {
|
||||
h, server := gitHandlerServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `{"success":%t}`, success)
|
||||
})
|
||||
defer server.Close()
|
||||
var callback []string
|
||||
h.SetOnBranchRenamed(func(_ context.Context, sessionID, newName, repo string) {
|
||||
callback = []string{sessionID, newName, repo}
|
||||
})
|
||||
msg, _ := ws.NewRequest("id", "action", GitRenameBranchRequest{SessionID: "s", NewName: "feature", Repo: "repo"})
|
||||
if _, err := h.wsRenameBranch(context.Background(), msg); err != nil {
|
||||
t.Fatalf("wsRenameBranch: %v", err)
|
||||
}
|
||||
if success && !reflect.DeepEqual(callback, []string{"s", "feature", "repo"}) {
|
||||
t.Fatalf("callback = %v", callback)
|
||||
}
|
||||
if !success && callback != nil {
|
||||
t.Fatalf("callback unexpectedly called: %v", callback)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCreatePRAssociatesSupportedChange(t *testing.T) {
|
||||
h, server := gitHandlerServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/git/create-pr" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["title"] != "Title" || body["repo"] != "repo" || body["draft"] != true {
|
||||
t.Errorf("body = %#v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true,"pr_url":"https://github.com/acme/repo/pull/1","provider":"github"}`))
|
||||
})
|
||||
defer server.Close()
|
||||
callback := make(chan []string, 1)
|
||||
h.SetOnPRCreated(func(_ context.Context, sessionID, taskID, provider, prURL, branch, repo string) {
|
||||
callback <- []string{sessionID, taskID, provider, prURL, branch, repo}
|
||||
})
|
||||
msg, _ := ws.NewRequest("id", "action", GitCreatePRRequest{SessionID: "s", Title: "Title", Draft: true, Repo: "repo"})
|
||||
response, err := h.wsCreatePR(context.Background(), msg)
|
||||
if err != nil || !strings.Contains(string(response.Payload), `"success":true`) {
|
||||
t.Fatalf("wsCreatePR = (%#v, %v)", response, err)
|
||||
}
|
||||
select {
|
||||
case got := <-callback:
|
||||
want := []string{"s", "task", "github", "https://github.com/acme/repo/pull/1", "", "repo"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("callback = %v, want %v", got, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for PR callback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCreatePRValidationAndDependencyFailure(t *testing.T) {
|
||||
h := NewGitHandlers(nil, nil, newTestLogger())
|
||||
for _, request := range []GitCreatePRRequest{{}, {SessionID: "s"}} {
|
||||
msg, _ := ws.NewRequest("id", "action", request)
|
||||
if _, err := h.wsCreatePR(context.Background(), msg); err == nil {
|
||||
t.Fatalf("expected validation error for %#v", request)
|
||||
}
|
||||
}
|
||||
h, server := gitHandlerServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "failure", http.StatusBadGateway)
|
||||
})
|
||||
defer server.Close()
|
||||
msg, _ := ws.NewRequest("id", "action", GitCreatePRRequest{SessionID: "s", Title: "Title"})
|
||||
if _, err := h.wsCreatePR(context.Background(), msg); err == nil || !strings.Contains(err.Error(), "create PR failed") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitMutationHandlersMapHTTPFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request any
|
||||
invoke func(*GitHandlers, context.Context, *ws.Message) (*ws.Message, error)
|
||||
want string
|
||||
}{
|
||||
{name: "rebase", request: GitRebaseRequest{SessionID: "s", BaseBranch: "main"}, invoke: (*GitHandlers).wsRebase, want: "rebase failed"},
|
||||
{name: "pull", request: GitPullRequest{SessionID: "s"}, invoke: (*GitHandlers).wsPull, want: "pull failed"},
|
||||
{name: "push", request: GitPushRequest{SessionID: "s"}, invoke: (*GitHandlers).wsPush, want: "push failed"},
|
||||
{name: "commit", request: GitCommitRequest{SessionID: "s", Message: "message"}, invoke: (*GitHandlers).wsCommit, want: "commit failed"},
|
||||
{name: "merge", request: GitMergeRequest{SessionID: "s", BaseBranch: "main"}, invoke: (*GitHandlers).wsMerge, want: "merge failed"},
|
||||
{name: "abort", request: GitAbortRequest{SessionID: "s", Operation: "merge"}, invoke: (*GitHandlers).wsAbort, want: "abort failed"},
|
||||
{name: "rename", request: GitRenameBranchRequest{SessionID: "s", NewName: "new"}, invoke: (*GitHandlers).wsRenameBranch, want: "rename branch failed"},
|
||||
{name: "reset", request: GitResetRequest{SessionID: "s", CommitSHA: "abc"}, invoke: (*GitHandlers).wsReset, want: "reset failed"},
|
||||
{name: "stage", request: GitStageRequest{SessionID: "s"}, invoke: (*GitHandlers).wsStage, want: "stage failed"},
|
||||
{name: "unstage", request: GitUnstageRequest{SessionID: "s"}, invoke: (*GitHandlers).wsUnstage, want: "unstage failed"},
|
||||
{name: "discard", request: GitDiscardRequest{SessionID: "s", Paths: []string{"a"}}, invoke: (*GitHandlers).wsDiscard, want: "discard failed"},
|
||||
{name: "revert", request: GitRevertCommitRequest{SessionID: "s", CommitSHA: "abc"}, invoke: (*GitHandlers).wsRevertCommit, want: "revert commit failed"},
|
||||
{name: "commit diff", request: GitShowCommitRequest{SessionID: "s", CommitSHA: "abc"}, invoke: (*GitHandlers).wsCommitDiff, want: "show commit failed"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h, server := gitHandlerServer(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "failure", http.StatusBadGateway) })
|
||||
defer server.Close()
|
||||
msg, _ := ws.NewRequest("id", "action", tt.request)
|
||||
_, err := tt.invoke(h, context.Background(), msg)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitMutationHandlersRejectInvalidRequestsBeforeLookup(t *testing.T) {
|
||||
h := NewGitHandlers(nil, nil, newTestLogger())
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
body any
|
||||
invoke func(*GitHandlers, context.Context, *ws.Message) (*ws.Message, error)
|
||||
want string
|
||||
}{
|
||||
{name: "pull malformed", action: ws.ActionWorktreePull, body: json.RawMessage(`{invalid`), invoke: (*GitHandlers).wsPull, want: "invalid payload"},
|
||||
{name: "rebase malformed", action: ws.ActionWorktreeRebase, body: json.RawMessage(`{invalid`), invoke: (*GitHandlers).wsRebase, want: "invalid payload"},
|
||||
{name: "merge malformed", action: ws.ActionWorktreeMerge, body: json.RawMessage(`{invalid`), invoke: (*GitHandlers).wsMerge, want: "invalid payload"},
|
||||
{name: "abort malformed", action: ws.ActionWorktreeAbort, body: json.RawMessage(`{invalid`), invoke: (*GitHandlers).wsAbort, want: "invalid payload"},
|
||||
{name: "reset malformed", action: ws.ActionWorktreeReset, body: json.RawMessage(`{invalid`), invoke: (*GitHandlers).wsReset, want: "invalid payload"},
|
||||
{name: "stage malformed", action: ws.ActionWorktreeStage, body: json.RawMessage(`{invalid`), invoke: (*GitHandlers).wsStage, want: "invalid payload"},
|
||||
{name: "discard malformed", action: ws.ActionWorktreeDiscard, body: json.RawMessage(`{invalid`), invoke: (*GitHandlers).wsDiscard, want: "invalid payload"},
|
||||
{name: "diff malformed", action: ws.ActionSessionCommitDiff, body: json.RawMessage(`{invalid`), invoke: (*GitHandlers).wsCommitDiff, want: "invalid payload"},
|
||||
{name: "push session", action: ws.ActionWorktreePush, body: GitPushRequest{}, invoke: (*GitHandlers).wsPush, want: "session_id is required"},
|
||||
{name: "rebase base", action: ws.ActionWorktreeRebase, body: GitRebaseRequest{SessionID: "s"}, invoke: (*GitHandlers).wsRebase, want: "base_branch is required"},
|
||||
{name: "merge base", action: ws.ActionWorktreeMerge, body: GitMergeRequest{SessionID: "s"}, invoke: (*GitHandlers).wsMerge, want: "base_branch is required"},
|
||||
{name: "abort operation", action: ws.ActionWorktreeAbort, body: GitAbortRequest{SessionID: "s", Operation: "cherry-pick"}, invoke: (*GitHandlers).wsAbort, want: "operation must be"},
|
||||
{name: "commit session", action: ws.ActionWorktreeCommit, body: GitCommitRequest{Message: "message"}, invoke: (*GitHandlers).wsCommit, want: "session_id is required"},
|
||||
{name: "rename name", action: ws.ActionWorktreeRenameBranch, body: GitRenameBranchRequest{SessionID: "s"}, invoke: (*GitHandlers).wsRenameBranch, want: "new_name is required"},
|
||||
{name: "reset sha", action: ws.ActionWorktreeReset, body: GitResetRequest{SessionID: "s"}, invoke: (*GitHandlers).wsReset, want: "commit_sha is required"},
|
||||
{name: "reset mode", action: ws.ActionWorktreeReset, body: GitResetRequest{SessionID: "s", CommitSHA: "abc", Mode: "invalid"}, invoke: (*GitHandlers).wsReset, want: "invalid reset mode"},
|
||||
{name: "stage session", action: ws.ActionWorktreeStage, body: GitStageRequest{}, invoke: (*GitHandlers).wsStage, want: "session_id is required"},
|
||||
{name: "unstage session", action: ws.ActionWorktreeUnstage, body: GitUnstageRequest{}, invoke: (*GitHandlers).wsUnstage, want: "session_id is required"},
|
||||
{name: "discard paths", action: ws.ActionWorktreeDiscard, body: GitDiscardRequest{SessionID: "s"}, invoke: (*GitHandlers).wsDiscard, want: "paths are required"},
|
||||
{name: "revert sha", action: ws.ActionWorktreeRevertCommit, body: GitRevertCommitRequest{SessionID: "s"}, invoke: (*GitHandlers).wsRevertCommit, want: "commit_sha is required"},
|
||||
{name: "diff sha", action: ws.ActionSessionCommitDiff, body: GitShowCommitRequest{SessionID: "s"}, invoke: (*GitHandlers).wsCommitDiff, want: "commit_sha is required"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var message *ws.Message
|
||||
if raw, ok := tt.body.(json.RawMessage); ok {
|
||||
message = &ws.Message{ID: "id", Action: tt.action, Payload: raw}
|
||||
} else {
|
||||
message, _ = ws.NewRequest("id", tt.action, tt.body)
|
||||
}
|
||||
_, err := tt.invoke(h, context.Background(), message)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func expectGitBody(key string, want any) func(*testing.T, map[string]any) {
|
||||
return func(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
if body[key] != want {
|
||||
t.Fatalf("body[%s] = %#v, want %#v", key, body[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func gitHandlerServer(t *testing.T, handler http.HandlerFunc) (*GitHandlers, *httptest.Server) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
u, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, err := strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
execution := &lifecycle.AgentExecution{SessionID: "s", TaskID: "task"}
|
||||
execution.SetAgentCtlClientForTesting(client.NewClient(u.Hostname(), port, newTestLogger()))
|
||||
lookup := &mockExecutionLookup{executions: map[string]*lifecycle.AgentExecution{"s": execution}}
|
||||
return NewGitHandlers(lookup, nil, newTestLogger()), server
|
||||
}
|
||||
|
||||
func TestNewGitHandlers(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
lookup := &mockExecutionLookup{}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/controller"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
func TestAgentHandlersRegisterEveryAction(t *testing.T) {
|
||||
h := NewHandlers(nil, newTestLogger())
|
||||
dispatcher := ws.NewDispatcher()
|
||||
h.RegisterHandlers(dispatcher)
|
||||
for _, action := range []string{
|
||||
ws.ActionAgentList,
|
||||
ws.ActionAgentLaunch,
|
||||
ws.ActionAgentStatus,
|
||||
ws.ActionAgentLogs,
|
||||
ws.ActionAgentStop,
|
||||
ws.ActionAgentTypes,
|
||||
} {
|
||||
if !dispatcher.HasHandler(action) {
|
||||
t.Fatalf("handler %s was not registered", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHandlersListStatusLogsAndTypes(t *testing.T) {
|
||||
mgr := newTestManager()
|
||||
execution := &lifecycle.AgentExecution{
|
||||
ID: "agent-1",
|
||||
TaskID: "task-1",
|
||||
AgentProfileID: "profile-1",
|
||||
Status: v1.AgentStatusRunning,
|
||||
}
|
||||
if err := mgr.ExecutionStoreForTesting().Add(execution); err != nil {
|
||||
t.Fatalf("add execution: %v", err)
|
||||
}
|
||||
h := NewHandlers(controller.NewController(mgr, newTestRegistry()), newTestLogger())
|
||||
|
||||
list, err := h.wsListAgents(context.Background(), agentHandlerMessage(t, ws.ActionAgentList, map[string]any{}))
|
||||
if err != nil {
|
||||
t.Fatalf("wsListAgents: %v", err)
|
||||
}
|
||||
var listPayload struct {
|
||||
Total int `json:"total"`
|
||||
Agents []struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id"`
|
||||
} `json:"agents"`
|
||||
}
|
||||
decodeHandlerPayload(t, list, &listPayload)
|
||||
if listPayload.Total != 1 || len(listPayload.Agents) != 1 || listPayload.Agents[0].ID != "agent-1" || listPayload.Agents[0].TaskID != "task-1" {
|
||||
t.Fatalf("list payload = %#v", listPayload)
|
||||
}
|
||||
|
||||
status, err := h.wsGetAgentStatus(context.Background(), agentHandlerMessage(t, ws.ActionAgentStatus, map[string]any{"agent_id": "agent-1"}))
|
||||
if err != nil {
|
||||
t.Fatalf("wsGetAgentStatus: %v", err)
|
||||
}
|
||||
var statusPayload struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
decodeHandlerPayload(t, status, &statusPayload)
|
||||
if statusPayload.ID != "agent-1" || statusPayload.Status != string(v1.AgentStatusRunning) {
|
||||
t.Fatalf("status payload = %#v", statusPayload)
|
||||
}
|
||||
|
||||
logs, err := h.wsGetAgentLogs(context.Background(), agentHandlerMessage(t, ws.ActionAgentLogs, map[string]any{"agent_id": "agent-1"}))
|
||||
if err != nil {
|
||||
t.Fatalf("wsGetAgentLogs: %v", err)
|
||||
}
|
||||
var logsPayload struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Logs []string `json:"logs"`
|
||||
}
|
||||
decodeHandlerPayload(t, logs, &logsPayload)
|
||||
if logsPayload.AgentID != "agent-1" || len(logsPayload.Logs) != 0 {
|
||||
t.Fatalf("logs payload = %#v", logsPayload)
|
||||
}
|
||||
|
||||
types, err := h.wsListAgentTypes(context.Background(), agentHandlerMessage(t, ws.ActionAgentTypes, map[string]any{}))
|
||||
if err != nil {
|
||||
t.Fatalf("wsListAgentTypes: %v", err)
|
||||
}
|
||||
var typesPayload struct {
|
||||
Types []any `json:"types"`
|
||||
}
|
||||
decodeHandlerPayload(t, types, &typesPayload)
|
||||
if len(typesPayload.Types) == 0 {
|
||||
t.Fatal("expected registered agent types")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHandlersValidation(t *testing.T) {
|
||||
h := NewHandlers(nil, newTestLogger())
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
invoke func(context.Context, *ws.Message) (*ws.Message, error)
|
||||
body any
|
||||
code string
|
||||
}{
|
||||
{name: "launch malformed", action: ws.ActionAgentLaunch, invoke: h.wsLaunchAgent, body: json.RawMessage(`{invalid`), code: ws.ErrorCodeBadRequest},
|
||||
{name: "launch task", action: ws.ActionAgentLaunch, invoke: h.wsLaunchAgent, body: map[string]any{}, code: ws.ErrorCodeValidation},
|
||||
{name: "launch profile", action: ws.ActionAgentLaunch, invoke: h.wsLaunchAgent, body: map[string]any{"task_id": "task"}, code: ws.ErrorCodeValidation},
|
||||
{name: "launch workspace", action: ws.ActionAgentLaunch, invoke: h.wsLaunchAgent, body: map[string]any{"task_id": "task", "agent_profile_id": "profile"}, code: ws.ErrorCodeValidation},
|
||||
{name: "status malformed", action: ws.ActionAgentStatus, invoke: h.wsGetAgentStatus, body: json.RawMessage(`{invalid`), code: ws.ErrorCodeBadRequest},
|
||||
{name: "status id", action: ws.ActionAgentStatus, invoke: h.wsGetAgentStatus, body: map[string]any{}, code: ws.ErrorCodeValidation},
|
||||
{name: "logs id", action: ws.ActionAgentLogs, invoke: h.wsGetAgentLogs, body: map[string]any{}, code: ws.ErrorCodeValidation},
|
||||
{name: "stop malformed", action: ws.ActionAgentStop, invoke: h.wsStopAgent, body: json.RawMessage(`{invalid`), code: ws.ErrorCodeBadRequest},
|
||||
{name: "stop id", action: ws.ActionAgentStop, invoke: h.wsStopAgent, body: map[string]any{}, code: ws.ErrorCodeValidation},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msg := agentHandlerMessage(t, tt.action, tt.body)
|
||||
response, err := tt.invoke(context.Background(), msg)
|
||||
if err != nil {
|
||||
t.Fatalf("handler error: %v", err)
|
||||
}
|
||||
assertWSErrorCode(t, response, tt.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHandlersMapControllerErrors(t *testing.T) {
|
||||
h := NewHandlers(controller.NewController(nil, nil), newTestLogger())
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
invoke func(context.Context, *ws.Message) (*ws.Message, error)
|
||||
body any
|
||||
}{
|
||||
{name: "list", action: ws.ActionAgentList, invoke: h.wsListAgents, body: map[string]any{}},
|
||||
{name: "launch", action: ws.ActionAgentLaunch, invoke: h.wsLaunchAgent, body: map[string]any{"task_id": "task", "agent_profile_id": "profile", "workspace_path": "/work"}},
|
||||
{name: "status", action: ws.ActionAgentStatus, invoke: h.wsGetAgentStatus, body: map[string]any{"agent_id": "missing"}},
|
||||
{name: "logs", action: ws.ActionAgentLogs, invoke: h.wsGetAgentLogs, body: map[string]any{"agent_id": "missing"}},
|
||||
{name: "stop", action: ws.ActionAgentStop, invoke: h.wsStopAgent, body: map[string]any{"agent_id": "missing"}},
|
||||
{name: "types", action: ws.ActionAgentTypes, invoke: h.wsListAgentTypes, body: map[string]any{}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
response, err := tt.invoke(context.Background(), agentHandlerMessage(t, tt.action, tt.body))
|
||||
if err != nil || response == nil {
|
||||
t.Fatalf("response = (%#v, %v)", response, err)
|
||||
}
|
||||
assertWSErrorCode(t, response, ws.ErrorCodeInternalError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStatusAndLogsMapMissingAgentToNotFound(t *testing.T) {
|
||||
h := NewHandlers(controller.NewController(newTestManager(), newTestRegistry()), newTestLogger())
|
||||
for _, test := range []struct {
|
||||
action string
|
||||
invoke func(context.Context, *ws.Message) (*ws.Message, error)
|
||||
}{
|
||||
{action: ws.ActionAgentStatus, invoke: h.wsGetAgentStatus},
|
||||
{action: ws.ActionAgentLogs, invoke: h.wsGetAgentLogs},
|
||||
} {
|
||||
response, err := test.invoke(context.Background(), agentHandlerMessage(t, test.action, map[string]any{"agent_id": "missing"}))
|
||||
if err != nil {
|
||||
t.Fatalf("handler error: %v", err)
|
||||
}
|
||||
assertWSErrorCode(t, response, ws.ErrorCodeNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func agentHandlerMessage(t *testing.T, action string, payload any) *ws.Message {
|
||||
t.Helper()
|
||||
if raw, ok := payload.(json.RawMessage); ok {
|
||||
return &ws.Message{ID: "id", Action: action, Payload: raw}
|
||||
}
|
||||
message, err := ws.NewRequest("id", action, payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func decodeHandlerPayload(t *testing.T, message *ws.Message, target any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(message.Payload, target); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,19 @@ import (
|
||||
// PassthroughHandlers provides WebSocket handlers for agent CLI passthrough operations.
|
||||
// These handlers route stdin to the agent process in passthrough mode.
|
||||
type PassthroughHandlers struct {
|
||||
lifecycleMgr *lifecycle.Manager
|
||||
lifecycleMgr passthroughLifecycle
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
type passthroughLifecycle interface {
|
||||
CheckSessionAccess(context.Context, string) error
|
||||
GetExecutionBySessionID(string) (*lifecycle.AgentExecution, bool)
|
||||
WritePassthroughStdin(context.Context, string, string) error
|
||||
ResizePassthroughPTY(context.Context, string, uint16, uint16) error
|
||||
}
|
||||
|
||||
// NewPassthroughHandlers creates a new PassthroughHandlers instance
|
||||
func NewPassthroughHandlers(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *PassthroughHandlers {
|
||||
func NewPassthroughHandlers(lifecycleMgr passthroughLifecycle, log *logger.Logger) *PassthroughHandlers {
|
||||
return &PassthroughHandlers{
|
||||
lifecycleMgr: lifecycleMgr,
|
||||
logger: log.WithFields(zap.String("component", "passthrough_handlers")),
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
type fakePassthroughLifecycle struct {
|
||||
accessErr error
|
||||
execution *lifecycle.AgentExecution
|
||||
writeErr error
|
||||
resizeErr error
|
||||
writeCalls int
|
||||
resizeCall int
|
||||
data string
|
||||
cols uint16
|
||||
rows uint16
|
||||
}
|
||||
|
||||
func (f *fakePassthroughLifecycle) CheckSessionAccess(context.Context, string) error {
|
||||
return f.accessErr
|
||||
}
|
||||
|
||||
func (f *fakePassthroughLifecycle) GetExecutionBySessionID(string) (*lifecycle.AgentExecution, bool) {
|
||||
return f.execution, f.execution != nil
|
||||
}
|
||||
|
||||
func (f *fakePassthroughLifecycle) WritePassthroughStdin(_ context.Context, _ string, data string) error {
|
||||
f.writeCalls++
|
||||
f.data = data
|
||||
return f.writeErr
|
||||
}
|
||||
|
||||
func (f *fakePassthroughLifecycle) ResizePassthroughPTY(_ context.Context, _ string, cols, rows uint16) error {
|
||||
f.resizeCall++
|
||||
f.cols, f.rows = cols, rows
|
||||
return f.resizeErr
|
||||
}
|
||||
|
||||
func TestPassthroughHandlersRegisterActions(t *testing.T) {
|
||||
h := NewPassthroughHandlers(&fakePassthroughLifecycle{}, newTestLogger())
|
||||
dispatcher := ws.NewDispatcher()
|
||||
h.RegisterHandlers(dispatcher)
|
||||
if !dispatcher.HasHandler(ws.ActionAgentStdin) || !dispatcher.HasHandler(ws.ActionAgentResize) {
|
||||
t.Fatal("passthrough handlers were not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassthroughStdinSuccessAndFailure(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
fake *fakePassthroughLifecycle
|
||||
wantErr string
|
||||
}{
|
||||
{name: "success", fake: &fakePassthroughLifecycle{execution: &lifecycle.AgentExecution{PassthroughProcessID: "pty"}}},
|
||||
{name: "no execution", fake: &fakePassthroughLifecycle{}, wantErr: "no agent running"},
|
||||
{name: "not passthrough", fake: &fakePassthroughLifecycle{execution: &lifecycle.AgentExecution{}}, wantErr: "not in passthrough mode"},
|
||||
{name: "write failure", fake: &fakePassthroughLifecycle{execution: &lifecycle.AgentExecution{PassthroughProcessID: "pty"}, writeErr: errors.New("closed")}, wantErr: "failed to send agent input"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := NewPassthroughHandlers(tt.fake, newTestLogger())
|
||||
msg, _ := ws.NewRequest("id", ws.ActionAgentStdin, AgentStdinRequest{SessionID: "s", Data: "hello\n"})
|
||||
response, err := h.wsAgentStdin(context.Background(), msg)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || response == nil || tt.fake.writeCalls != 1 || tt.fake.data != "hello\n" {
|
||||
t.Fatalf("result = (%#v, %v), fake=%+v", response, err, tt.fake)
|
||||
}
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(response.Payload, &payload)
|
||||
if payload["success"] != true {
|
||||
t.Fatalf("payload = %v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassthroughResizeValidationSuccessAndFailure(t *testing.T) {
|
||||
for _, size := range []AgentResizeRequest{{SessionID: "s", Cols: 0, Rows: 24}, {SessionID: "s", Cols: 80, Rows: 0}} {
|
||||
fake := &fakePassthroughLifecycle{execution: &lifecycle.AgentExecution{PassthroughProcessID: "pty"}}
|
||||
h := NewPassthroughHandlers(fake, newTestLogger())
|
||||
msg, _ := ws.NewRequest("id", ws.ActionAgentResize, size)
|
||||
if _, err := h.wsAgentResize(context.Background(), msg); err == nil || fake.resizeCall != 0 {
|
||||
t.Fatalf("validation result = %v, calls=%d", err, fake.resizeCall)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
fake *fakePassthroughLifecycle
|
||||
wantErr string
|
||||
}{
|
||||
{name: "success", fake: &fakePassthroughLifecycle{execution: &lifecycle.AgentExecution{PassthroughProcessID: "pty"}}},
|
||||
{name: "no execution", fake: &fakePassthroughLifecycle{}, wantErr: "no agent running"},
|
||||
{name: "not passthrough", fake: &fakePassthroughLifecycle{execution: &lifecycle.AgentExecution{}}, wantErr: "not in passthrough mode"},
|
||||
{name: "resize failure", fake: &fakePassthroughLifecycle{execution: &lifecycle.AgentExecution{PassthroughProcessID: "pty"}, resizeErr: errors.New("closed")}, wantErr: "failed to resize terminal"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := NewPassthroughHandlers(tt.fake, newTestLogger())
|
||||
msg, _ := ws.NewRequest("id", ws.ActionAgentResize, AgentResizeRequest{SessionID: "s", Cols: 80, Rows: 24})
|
||||
response, err := h.wsAgentResize(context.Background(), msg)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || response == nil || tt.fake.resizeCall != 1 || tt.fake.cols != 80 || tt.fake.rows != 24 {
|
||||
t.Fatalf("result = (%#v, %v), fake=%+v", response, err, tt.fake)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"go.uber.org/zap"
|
||||
@@ -21,13 +20,13 @@ type TunnelController interface {
|
||||
|
||||
// PortHandlers provides WebSocket handlers for port listing and tunnel operations.
|
||||
type PortHandlers struct {
|
||||
lifecycleMgr *lifecycle.Manager
|
||||
lifecycleMgr ExecutionLookup
|
||||
tunnelCtrl TunnelController
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewPortHandlers creates a new PortHandlers instance.
|
||||
func NewPortHandlers(lifecycleMgr *lifecycle.Manager, tunnelCtrl TunnelController, log *logger.Logger) *PortHandlers {
|
||||
func NewPortHandlers(lifecycleMgr ExecutionLookup, tunnelCtrl TunnelController, log *logger.Logger) *PortHandlers {
|
||||
return &PortHandlers{
|
||||
lifecycleMgr: lifecycleMgr,
|
||||
tunnelCtrl: tunnelCtrl,
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
type recordingTunnelController struct {
|
||||
startSession string
|
||||
startPort int
|
||||
requested int
|
||||
startResult int
|
||||
startErr error
|
||||
stopSession string
|
||||
stopPort int
|
||||
stopErr error
|
||||
tunnels any
|
||||
}
|
||||
|
||||
func (c *recordingTunnelController) StartTunnel(sessionID string, port, tunnelPort int) (int, error) {
|
||||
c.startSession, c.startPort, c.requested = sessionID, port, tunnelPort
|
||||
return c.startResult, c.startErr
|
||||
}
|
||||
|
||||
func (c *recordingTunnelController) StopTunnel(sessionID string, port int) error {
|
||||
c.stopSession, c.stopPort = sessionID, port
|
||||
return c.stopErr
|
||||
}
|
||||
|
||||
func (c *recordingTunnelController) ListTunnels(string) any { return c.tunnels }
|
||||
|
||||
func TestPortHandlersRegistrationDependsOnTunnelController(t *testing.T) {
|
||||
dispatcher := ws.NewDispatcher()
|
||||
NewPortHandlers(nil, nil, newTestLogger()).RegisterHandlers(dispatcher)
|
||||
if !dispatcher.HasHandler(ws.ActionPortList) {
|
||||
t.Fatal("port list handler was not registered")
|
||||
}
|
||||
if dispatcher.HasHandler(ws.ActionPortTunnelStart) {
|
||||
t.Fatal("tunnel handler registered without a controller")
|
||||
}
|
||||
|
||||
dispatcher = ws.NewDispatcher()
|
||||
NewPortHandlers(nil, &recordingTunnelController{}, newTestLogger()).RegisterHandlers(dispatcher)
|
||||
for _, action := range []string{ws.ActionPortTunnelStart, ws.ActionPortTunnelStop, ws.ActionPortTunnelList} {
|
||||
if !dispatcher.HasHandler(action) {
|
||||
t.Fatalf("handler %s was not registered", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortListSuccessAndErrors(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
wantCode string
|
||||
}{
|
||||
{name: "success", statusCode: http.StatusOK, body: `{"ports":[{"port":3000,"address":"127.0.0.1","protocol":"tcp","process":"vite"}]}`},
|
||||
{name: "dependency failure", statusCode: http.StatusBadGateway, body: `failure`, wantCode: ws.ErrorCodeInternalError},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/ports" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(tt.statusCode)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
u, _ := url.Parse(server.URL)
|
||||
port, _ := strconv.Atoi(u.Port())
|
||||
client := agentctl.NewClient(u.Hostname(), port, newTestLogger())
|
||||
execution := &lifecycle.AgentExecution{SessionID: "s"}
|
||||
execution.SetAgentCtlClientForTesting(client)
|
||||
h := NewPortHandlers(&mockExecutionLookup{executions: map[string]*lifecycle.AgentExecution{"s": execution}}, nil, newTestLogger())
|
||||
msg, _ := ws.NewRequest("id", ws.ActionPortList, map[string]any{"session_id": "s"})
|
||||
response, err := h.wsPortList(context.Background(), msg)
|
||||
if err != nil {
|
||||
t.Fatalf("wsPortList: %v", err)
|
||||
}
|
||||
if tt.wantCode != "" {
|
||||
assertWSErrorCode(t, response, tt.wantCode)
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
Ports []struct {
|
||||
Port int `json:"port"`
|
||||
} `json:"ports"`
|
||||
}
|
||||
decodeHandlerPayload(t, response, &payload)
|
||||
if len(payload.Ports) != 1 || payload.Ports[0].Port != 3000 {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortListValidationAndExecutionErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
message *ws.Message
|
||||
lookup ExecutionLookup
|
||||
code string
|
||||
}{
|
||||
{name: "malformed", message: &ws.Message{ID: "id", Action: ws.ActionPortList, Payload: json.RawMessage(`{invalid`)}, code: ws.ErrorCodeBadRequest},
|
||||
{name: "session required", message: tunnelTestMessage(ws.ActionPortList, map[string]any{}), code: ws.ErrorCodeValidation},
|
||||
{name: "execution missing", message: tunnelTestMessage(ws.ActionPortList, map[string]any{"session_id": "s"}), lookup: &mockExecutionLookup{}, code: ws.ErrorCodeNotFound},
|
||||
{name: "client missing", message: tunnelTestMessage(ws.ActionPortList, map[string]any{"session_id": "s"}), lookup: &mockExecutionLookup{executions: map[string]*lifecycle.AgentExecution{"s": {}}}, code: ws.ErrorCodeInternalError},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := NewPortHandlers(tt.lookup, nil, newTestLogger())
|
||||
response, err := h.wsPortList(context.Background(), tt.message)
|
||||
if err != nil {
|
||||
t.Fatalf("wsPortList: %v", err)
|
||||
}
|
||||
assertWSErrorCode(t, response, tt.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelStartValidationDoesNotCallController(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload any
|
||||
code string
|
||||
}{
|
||||
{name: "malformed", payload: "invalid", code: ws.ErrorCodeBadRequest},
|
||||
{name: "missing session", payload: map[string]any{"port": 3000}, code: ws.ErrorCodeValidation},
|
||||
{name: "port too low", payload: map[string]any{"session_id": "s", "port": 0}, code: ws.ErrorCodeValidation},
|
||||
{name: "port too high", payload: map[string]any{"session_id": "s", "port": 65536}, code: ws.ErrorCodeValidation},
|
||||
{name: "tunnel port too high", payload: map[string]any{"session_id": "s", "port": 3000, "tunnel_port": 65536}, code: ws.ErrorCodeValidation},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctrl := &recordingTunnelController{}
|
||||
h := NewPortHandlers(nil, ctrl, newTestLogger())
|
||||
resp, err := h.wsTunnelStart(context.Background(), tunnelTestMessage(ws.ActionPortTunnelStart, tt.payload))
|
||||
if err != nil {
|
||||
t.Fatalf("wsTunnelStart returned error: %v", err)
|
||||
}
|
||||
assertWSErrorCode(t, resp, tt.code)
|
||||
if ctrl.startSession != "" {
|
||||
t.Fatal("controller called for invalid request")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelStartForwardsRequestAndReturnsAllocatedPort(t *testing.T) {
|
||||
ctrl := &recordingTunnelController{startResult: 43123}
|
||||
h := NewPortHandlers(nil, ctrl, newTestLogger())
|
||||
resp, err := h.wsTunnelStart(context.Background(), tunnelTestMessage(ws.ActionPortTunnelStart, map[string]any{
|
||||
"session_id": "session-1", "port": 3000, "tunnel_port": 0,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("wsTunnelStart returned error: %v", err)
|
||||
}
|
||||
if ctrl.startSession != "session-1" || ctrl.startPort != 3000 || ctrl.requested != 0 {
|
||||
t.Fatalf("unexpected forwarded request: %+v", ctrl)
|
||||
}
|
||||
assertResponseField(t, resp, "tunnel_port", float64(43123))
|
||||
}
|
||||
|
||||
func TestTunnelStartMapsControllerFailure(t *testing.T) {
|
||||
ctrl := &recordingTunnelController{startErr: errors.New("bind failed")}
|
||||
h := NewPortHandlers(nil, ctrl, newTestLogger())
|
||||
resp, err := h.wsTunnelStart(context.Background(), tunnelTestMessage(ws.ActionPortTunnelStart, map[string]any{
|
||||
"session_id": "session-1", "port": 3000,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("wsTunnelStart returned error: %v", err)
|
||||
}
|
||||
assertWSErrorCode(t, resp, ws.ErrorCodeInternalError)
|
||||
}
|
||||
|
||||
func TestTunnelStopAndListContracts(t *testing.T) {
|
||||
ctrl := &recordingTunnelController{tunnels: []map[string]any{{"port": 3000, "tunnel_port": 43123}}}
|
||||
h := NewPortHandlers(nil, ctrl, newTestLogger())
|
||||
stop, err := h.wsTunnelStop(context.Background(), tunnelTestMessage(ws.ActionPortTunnelStop, map[string]any{
|
||||
"session_id": "session-1", "port": 3000,
|
||||
}))
|
||||
if err != nil || stop == nil {
|
||||
t.Fatalf("wsTunnelStop = (%v, %v)", stop, err)
|
||||
}
|
||||
if ctrl.stopSession != "session-1" || ctrl.stopPort != 3000 {
|
||||
t.Fatalf("unexpected stop request: %+v", ctrl)
|
||||
}
|
||||
|
||||
list, err := h.wsTunnelList(context.Background(), tunnelTestMessage(ws.ActionPortTunnelList, map[string]any{"session_id": "session-1"}))
|
||||
if err != nil {
|
||||
t.Fatalf("wsTunnelList returned error: %v", err)
|
||||
}
|
||||
assertResponseField(t, list, "tunnels", []any{map[string]any{"port": float64(3000), "tunnel_port": float64(43123)}})
|
||||
}
|
||||
|
||||
func TestTunnelStopValidationAndFailure(t *testing.T) {
|
||||
ctrl := &recordingTunnelController{stopErr: errors.New("missing tunnel")}
|
||||
h := NewPortHandlers(nil, ctrl, newTestLogger())
|
||||
for _, payload := range []any{"invalid", map[string]any{"port": 3000}, map[string]any{"session_id": "s", "port": 0}} {
|
||||
resp, err := h.wsTunnelStop(context.Background(), tunnelTestMessage(ws.ActionPortTunnelStop, payload))
|
||||
if err != nil || resp == nil {
|
||||
t.Fatalf("validation response = (%v, %v)", resp, err)
|
||||
}
|
||||
}
|
||||
resp, err := h.wsTunnelStop(context.Background(), tunnelTestMessage(ws.ActionPortTunnelStop, map[string]any{"session_id": "s", "port": 3000}))
|
||||
if err != nil {
|
||||
t.Fatalf("wsTunnelStop returned error: %v", err)
|
||||
}
|
||||
assertWSErrorCode(t, resp, ws.ErrorCodeInternalError)
|
||||
}
|
||||
|
||||
func TestTunnelListValidation(t *testing.T) {
|
||||
h := NewPortHandlers(nil, &recordingTunnelController{}, newTestLogger())
|
||||
for _, payload := range []any{"invalid", map[string]any{}} {
|
||||
resp, err := h.wsTunnelList(context.Background(), tunnelTestMessage(ws.ActionPortTunnelList, payload))
|
||||
if err != nil || resp == nil {
|
||||
t.Fatalf("validation response = (%v, %v)", resp, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertWSErrorCode(t *testing.T, message *ws.Message, want string) {
|
||||
t.Helper()
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(message.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if got := payload["code"]; got != want {
|
||||
t.Fatalf("error code = %v, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertResponseField(t *testing.T, message *ws.Message, field string, want any) {
|
||||
t.Helper()
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(message.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if got := payload[field]; !deepEqualJSON(got, want) {
|
||||
t.Fatalf("%s = %#v, want %#v", field, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func tunnelTestMessage(action string, payload any) *ws.Message {
|
||||
if payload == "invalid" {
|
||||
return &ws.Message{ID: "1", Action: action, Payload: json.RawMessage(`{invalid`)}
|
||||
}
|
||||
message, _ := ws.NewRequest("1", action, payload)
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
)
|
||||
|
||||
func TestReviewChangeSourceExecutionFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source *ReviewChangeSource
|
||||
want string
|
||||
}{
|
||||
{name: "lookup missing", source: NewReviewChangeSource(nil, nil), want: "no execution lookup configured"},
|
||||
{name: "lookup error", source: NewReviewChangeSource(&mockExecutionLookup{ensureErr: errors.New("database unavailable")}, nil), want: "execution for session s: database unavailable"},
|
||||
{name: "execution missing client", source: NewReviewChangeSource(&mockExecutionLookup{executions: map[string]*lifecycle.AgentExecution{"s": {}}}, nil), want: "session s workspace is not ready"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := tt.source.UncommittedFiles(context.Background(), "s")
|
||||
if err == nil || err.Error() != tt.want {
|
||||
t.Fatalf("error = %v, want %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewChangeSourceUncommittedFiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
want map[string]any
|
||||
wantErr string
|
||||
}{
|
||||
{name: "files", statusCode: http.StatusOK, body: `{"files":{"main.go":{"status":"modified"}}}`, want: map[string]any{"main.go": map[string]any{"status": "modified"}}},
|
||||
{name: "nil status", statusCode: http.StatusOK, body: `null`},
|
||||
{name: "dependency error", statusCode: http.StatusBadGateway, body: `broken`, wantErr: "git status for session s:"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
source := reviewSourceServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/git/status" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(tt.statusCode)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
})
|
||||
got, err := source.UncommittedFiles(context.Background(), "s")
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("UncommittedFiles: %v", err)
|
||||
}
|
||||
if !deepEqualJSON(got, tt.want) {
|
||||
t.Fatalf("files = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewChangeSourceCommittedFilesUsesSessionBase(t *testing.T) {
|
||||
var query url.Values
|
||||
source := reviewSourceServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
query = r.URL.Query()
|
||||
_, _ = w.Write([]byte(`{"files":{"a.go":{"additions":3}}}`))
|
||||
})
|
||||
source.sessionReader = &mockSessionReader{baseCommits: map[string]string{"s": "abc123"}, baseBranches: map[string]string{"s": "main"}}
|
||||
|
||||
files, err := source.CommittedFiles(context.Background(), "s")
|
||||
if err != nil {
|
||||
t.Fatalf("CommittedFiles: %v", err)
|
||||
}
|
||||
if query.Get("base") != "abc123" || query.Get("target_branch") != "main" {
|
||||
t.Fatalf("query = %v", query)
|
||||
}
|
||||
if !deepEqualJSON(files, map[string]any{"a.go": map[string]any{"additions": float64(3)}}) {
|
||||
t.Fatalf("files = %#v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewChangeSourceCommittedFilesFallsBackToStatusBase(t *testing.T) {
|
||||
requests := 0
|
||||
source := reviewSourceServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/git/status":
|
||||
_, _ = w.Write([]byte(`{"base_commit":"fallback"}`))
|
||||
case "/api/v1/git/cumulative-diff":
|
||||
if r.URL.Query().Get("base") != "fallback" {
|
||||
t.Errorf("base = %q", r.URL.Query().Get("base"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`null`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
files, err := source.CommittedFiles(context.Background(), "s")
|
||||
if err != nil || files != nil || requests != 2 {
|
||||
t.Fatalf("CommittedFiles = (%#v, %v), requests=%d", files, err, requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewChangeSourceCommittedFilesReturnsEmptyWithoutBase(t *testing.T) {
|
||||
for _, body := range []string{`null`, `{}`, `broken`} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
source := reviewSourceServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
if body == "broken" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
files, err := source.CommittedFiles(context.Background(), "s")
|
||||
if err != nil || files != nil {
|
||||
t.Fatalf("CommittedFiles = (%#v, %v)", files, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewChangeSourceCommittedFilesWrapsDiffFailure(t *testing.T) {
|
||||
source := reviewSourceServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("broken"))
|
||||
})
|
||||
source.sessionReader = &mockSessionReader{baseCommits: map[string]string{"s": "base"}}
|
||||
_, err := source.CommittedFiles(context.Background(), "s")
|
||||
if err == nil || !strings.Contains(err.Error(), "cumulative diff for session s:") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func reviewSourceServer(t *testing.T, handler http.HandlerFunc) *ReviewChangeSource {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
u, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, err := strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := agentctl.NewClient(u.Hostname(), port, newTestLogger())
|
||||
execution := &lifecycle.AgentExecution{SessionID: "s"}
|
||||
execution.SetAgentCtlClientForTesting(client)
|
||||
lookup := &mockExecutionLookup{executions: map[string]*lifecycle.AgentExecution{"s": execution}}
|
||||
return NewReviewChangeSource(lookup, nil)
|
||||
}
|
||||
|
||||
func deepEqualJSON(got, want any) bool {
|
||||
return reflect.DeepEqual(got, want)
|
||||
}
|
||||
@@ -4,14 +4,18 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kandev/kandev/internal/agent/registry"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
terminalservice "github.com/kandev/kandev/internal/terminal/service"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
@@ -97,6 +101,77 @@ func TestNewShellHandlers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellPresentationHelpers(t *testing.T) {
|
||||
for id, want := range map[string]string{terminalservice.BottomPanelID: "fixed", "custom": "script"} {
|
||||
if got := kindForUnmanaged(id); got != want {
|
||||
t.Fatalf("kindForUnmanaged(%q) = %q, want %q", id, got, want)
|
||||
}
|
||||
}
|
||||
if ptyStatusFromRunning(true) != terminalservice.PTYStatusRunning || ptyStatusFromRunning(false) != terminalservice.PTYStatusStopped {
|
||||
t.Fatal("unexpected PTY status mapping")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellStateHandlersValidateAndRequireService(t *testing.T) {
|
||||
h := NewShellHandlers(newTestManager(), nil, newTestLogger())
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
body any
|
||||
invoke func(context.Context, *ws.Message) (*ws.Message, error)
|
||||
want string
|
||||
}{
|
||||
{name: "rename malformed", action: ws.ActionUserShellRename, body: json.RawMessage(`{invalid`), invoke: h.wsUserShellRename, want: "invalid payload"},
|
||||
{name: "rename id", action: ws.ActionUserShellRename, body: UserShellRenameRequest{}, invoke: h.wsUserShellRename, want: "terminal_id is required"},
|
||||
{name: "rename service", action: ws.ActionUserShellRename, body: UserShellRenameRequest{TerminalID: "terminal"}, invoke: h.wsUserShellRename, want: "terminal service not available"},
|
||||
{name: "park id", action: ws.ActionUserShellPark, body: UserShellStateRequest{}, invoke: h.wsUserShellPark, want: "terminal_id is required"},
|
||||
{name: "park service", action: ws.ActionUserShellPark, body: UserShellStateRequest{TerminalID: "terminal"}, invoke: h.wsUserShellPark, want: "terminal service not available"},
|
||||
{name: "resume id", action: ws.ActionUserShellResume, body: UserShellStateRequest{}, invoke: h.wsUserShellResume, want: "terminal_id is required"},
|
||||
{name: "resume service", action: ws.ActionUserShellResume, body: UserShellStateRequest{TerminalID: "terminal"}, invoke: h.wsUserShellResume, want: "terminal service not available"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var message *ws.Message
|
||||
if raw, ok := tt.body.(json.RawMessage); ok {
|
||||
message = &ws.Message{ID: "id", Action: tt.action, Payload: raw}
|
||||
} else {
|
||||
message, _ = ws.NewRequest("id", tt.action, tt.body)
|
||||
}
|
||||
_, err := tt.invoke(context.Background(), message)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellHTTPRoutesReturnEmptyWithoutRunners(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
h := NewShellHandlers(newTestManager(), nil, newTestLogger())
|
||||
RegisterShellRoutesOn(router, h)
|
||||
|
||||
for _, path := range []string{"/api/v1/environments/env-1/terminals", "/api/v1/tasks/task-1/terminals?task_environment_id=env-1"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"terminals":[]`) {
|
||||
t.Fatalf("%s response = %d %s", path, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShellRoutesBuildsLegacyHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
RegisterShellRoutes(router, newTestManager(), newTestLogger())
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/environments/env/terminals", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHandlers(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
handlers := NewShellHandlers(nil, nil, log)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
@@ -19,13 +18,13 @@ type ProxyInvalidator interface {
|
||||
|
||||
// VscodeHandlers provides WebSocket handlers for VS Code server operations.
|
||||
type VscodeHandlers struct {
|
||||
lifecycleMgr *lifecycle.Manager
|
||||
lifecycleMgr ExecutionLookup
|
||||
proxyInvalidator ProxyInvalidator
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewVscodeHandlers creates a new VscodeHandlers instance.
|
||||
func NewVscodeHandlers(lifecycleMgr *lifecycle.Manager, proxyInvalidator ProxyInvalidator, log *logger.Logger) *VscodeHandlers {
|
||||
func NewVscodeHandlers(lifecycleMgr ExecutionLookup, proxyInvalidator ProxyInvalidator, log *logger.Logger) *VscodeHandlers {
|
||||
return &VscodeHandlers{
|
||||
lifecycleMgr: lifecycleMgr,
|
||||
proxyInvalidator: proxyInvalidator,
|
||||
|
||||
@@ -3,8 +3,15 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
@@ -30,6 +37,113 @@ func TestNewVscodeHandlers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVscodeProxyURL(t *testing.T) {
|
||||
if got := buildVscodeProxyURL("session", ""); got != "/vscode/session/" {
|
||||
t.Fatalf("URL = %q", got)
|
||||
}
|
||||
if got := buildVscodeProxyURL("session", "/work/my repo"); got != "/vscode/session/?folder=%2Fwork%2Fmy+repo" {
|
||||
t.Fatalf("URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVscodeHandlersSuccessContracts(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/vscode/start":
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["theme"] != "dark" {
|
||||
t.Errorf("start body = %v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true,"status":"starting","port":43123}`))
|
||||
case "/api/v1/vscode/status":
|
||||
_, _ = w.Write([]byte(`{"status":"running","port":43123,"url":"internal"}`))
|
||||
case "/api/v1/vscode/open-file":
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["path"] != "main.go" || body["line"] != float64(12) {
|
||||
t.Errorf("open body = %v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
case "/api/v1/vscode/stop":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
h, proxy := vscodeHandlerServer(t, server)
|
||||
|
||||
start, err := h.wsVscodeStart(context.Background(), vscodeMessage(t, ws.ActionVscodeStart, VscodeStartRequest{SessionID: "s", Theme: "dark"}))
|
||||
if err != nil || !strings.Contains(string(start.Payload), `"port":43123`) || !strings.Contains(string(start.Payload), `folder=%2Fwork%2Fmy+repo`) {
|
||||
t.Fatalf("start = (%s, %v)", start.Payload, err)
|
||||
}
|
||||
status, err := h.wsVscodeStatus(context.Background(), vscodeMessage(t, ws.ActionVscodeStatus, VscodeStatusRequest{SessionID: "s"}))
|
||||
if err != nil || !strings.Contains(string(status.Payload), `"status":"running"`) || strings.Contains(string(status.Payload), `"url":"internal"`) {
|
||||
t.Fatalf("status = (%s, %v)", status.Payload, err)
|
||||
}
|
||||
opened, err := h.wsVscodeOpenFile(context.Background(), vscodeMessage(t, ws.ActionVscodeOpenFile, VscodeOpenFileRequest{SessionID: "s", Path: "main.go", Line: 12, Col: 3}))
|
||||
if err != nil || !strings.Contains(string(opened.Payload), `"success":true`) {
|
||||
t.Fatalf("open = (%s, %v)", opened.Payload, err)
|
||||
}
|
||||
stopped, err := h.wsVscodeStop(context.Background(), vscodeMessage(t, ws.ActionVscodeStop, VscodeStopRequest{SessionID: "s"}))
|
||||
if err != nil || !strings.Contains(string(stopped.Payload), `"status":"stopped"`) || len(proxy.invalidated) != 1 || proxy.invalidated[0] != "s" {
|
||||
t.Fatalf("stop = (%s, %v), invalidated=%v", stopped.Payload, err, proxy.invalidated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVscodeHandlersMapDependencyFailures(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "failure", http.StatusBadGateway)
|
||||
}))
|
||||
defer server.Close()
|
||||
h, _ := vscodeHandlerServer(t, server)
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
body any
|
||||
invoke func(context.Context, *ws.Message) (*ws.Message, error)
|
||||
}{
|
||||
{name: "start", action: ws.ActionVscodeStart, body: VscodeStartRequest{SessionID: "s"}, invoke: h.wsVscodeStart},
|
||||
{name: "stop", action: ws.ActionVscodeStop, body: VscodeStopRequest{SessionID: "s"}, invoke: h.wsVscodeStop},
|
||||
{name: "open", action: ws.ActionVscodeOpenFile, body: VscodeOpenFileRequest{SessionID: "s", Path: "a"}, invoke: h.wsVscodeOpenFile},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
response, err := tt.invoke(context.Background(), vscodeMessage(t, tt.action, tt.body))
|
||||
if err != nil {
|
||||
t.Fatalf("handler error: %v", err)
|
||||
}
|
||||
assertWSErrorCode(t, response, ws.ErrorCodeInternalError)
|
||||
})
|
||||
}
|
||||
status, err := h.wsVscodeStatus(context.Background(), vscodeMessage(t, ws.ActionVscodeStatus, VscodeStatusRequest{SessionID: "s"}))
|
||||
if err != nil || !strings.Contains(string(status.Payload), `"status":"stopped"`) {
|
||||
t.Fatalf("status = (%s, %v)", status.Payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func vscodeHandlerServer(t *testing.T, server *httptest.Server) (*VscodeHandlers, *mockProxyInvalidator) {
|
||||
t.Helper()
|
||||
u, _ := url.Parse(server.URL)
|
||||
port, _ := strconv.Atoi(u.Port())
|
||||
client := agentctl.NewClient(u.Hostname(), port, newTestLogger())
|
||||
execution := &lifecycle.AgentExecution{SessionID: "s", WorkspacePath: "/work/my repo"}
|
||||
execution.SetAgentCtlClientForTesting(client)
|
||||
lookup := &mockExecutionLookup{executions: map[string]*lifecycle.AgentExecution{"s": execution}}
|
||||
proxy := &mockProxyInvalidator{}
|
||||
return NewVscodeHandlers(lookup, proxy, newTestLogger()), proxy
|
||||
}
|
||||
|
||||
func vscodeMessage(t *testing.T, action string, payload any) *ws.Message {
|
||||
t.Helper()
|
||||
message, err := ws.NewRequest("id", action, payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func TestRegisterVscodeHandlers(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
h := NewVscodeHandlers(nil, nil, log)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
@@ -16,7 +15,7 @@ import (
|
||||
|
||||
// WorkspaceFileHandlers handles workspace file operations
|
||||
type WorkspaceFileHandlers struct {
|
||||
lifecycle *lifecycle.Manager
|
||||
lifecycle ExecutionLookup
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
@@ -27,7 +26,7 @@ type workspaceContentSearchRequest struct {
|
||||
}
|
||||
|
||||
// NewWorkspaceFileHandlers creates new workspace file handlers
|
||||
func NewWorkspaceFileHandlers(lm *lifecycle.Manager, log *logger.Logger) *WorkspaceFileHandlers {
|
||||
func NewWorkspaceFileHandlers(lm ExecutionLookup, log *logger.Logger) *WorkspaceFileHandlers {
|
||||
return &WorkspaceFileHandlers{
|
||||
lifecycle: lm,
|
||||
logger: log.WithFields(zap.String("component", "workspace-file-handlers")),
|
||||
|
||||
@@ -2,12 +2,156 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
func TestWorkspaceFileHandlersValidateRequestsBeforeLookup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
payload any
|
||||
invoke func(*WorkspaceFileHandlers, context.Context, *ws.Message) (*ws.Message, error)
|
||||
}{
|
||||
{name: "tree session", action: ws.ActionWorkspaceFileTreeGet, payload: map[string]any{}, invoke: (*WorkspaceFileHandlers).wsGetFileTree},
|
||||
{name: "content path", action: ws.ActionWorkspaceFileContentGet, payload: map[string]any{"session_id": "s"}, invoke: (*WorkspaceFileHandlers).wsGetFileContent},
|
||||
{name: "ref path", action: ws.ActionWorkspaceFileContentGetRef, payload: map[string]any{"session_id": "s"}, invoke: (*WorkspaceFileHandlers).wsGetFileContentAtRef},
|
||||
{name: "ref value", action: ws.ActionWorkspaceFileContentGetRef, payload: map[string]any{"session_id": "s", "path": "a"}, invoke: (*WorkspaceFileHandlers).wsGetFileContentAtRef},
|
||||
{name: "update diff", action: ws.ActionWorkspaceFileContentUpdate, payload: map[string]any{"session_id": "s", "path": "a"}, invoke: (*WorkspaceFileHandlers).wsUpdateFileContent},
|
||||
{name: "create path", action: ws.ActionWorkspaceFileCreate, payload: map[string]any{"session_id": "s"}, invoke: (*WorkspaceFileHandlers).wsCreateFile},
|
||||
{name: "delete session", action: ws.ActionWorkspaceFileDelete, payload: map[string]any{}, invoke: (*WorkspaceFileHandlers).wsDeleteFile},
|
||||
{name: "search session", action: ws.ActionWorkspaceFilesSearch, payload: map[string]any{}, invoke: (*WorkspaceFileHandlers).wsSearchFiles},
|
||||
{name: "rename old path", action: ws.ActionWorkspaceFileRename, payload: map[string]any{"session_id": "s"}, invoke: (*WorkspaceFileHandlers).wsRenameFile},
|
||||
{name: "rename new path", action: ws.ActionWorkspaceFileRename, payload: map[string]any{"session_id": "s", "old_path": "a"}, invoke: (*WorkspaceFileHandlers).wsRenameFile},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := NewWorkspaceFileHandlers(nil, newTestLogger())
|
||||
msg, _ := ws.NewRequest("id", tt.action, tt.payload)
|
||||
response, err := tt.invoke(h, context.Background(), msg)
|
||||
if err != nil || response == nil {
|
||||
t.Fatalf("response = (%#v, %v)", response, err)
|
||||
}
|
||||
assertWorkspaceContentSearchErrorCode(t, response, ws.ErrorCodeValidation)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceFileHandlersSuccessContracts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
path string
|
||||
method string
|
||||
payload any
|
||||
invoke func(*WorkspaceFileHandlers, context.Context, *ws.Message) (*ws.Message, error)
|
||||
assertReq func(*testing.T, *http.Request)
|
||||
}{
|
||||
{name: "tree", action: ws.ActionWorkspaceFileTreeGet, path: "/api/v1/workspace/tree", method: http.MethodGet, payload: map[string]any{"session_id": "s", "path": "src", "depth": 2}, invoke: (*WorkspaceFileHandlers).wsGetFileTree, assertReq: expectWorkspaceQuery("path", "src")},
|
||||
{name: "content", action: ws.ActionWorkspaceFileContentGet, path: "/api/v1/workspace/file/content", method: http.MethodGet, payload: map[string]any{"session_id": "s", "path": "a.go", "repo": "repo"}, invoke: (*WorkspaceFileHandlers).wsGetFileContent, assertReq: expectWorkspaceQuery("repo", "repo")},
|
||||
{name: "content at ref", action: ws.ActionWorkspaceFileContentGetRef, path: "/api/v1/workspace/file/content-at-ref", method: http.MethodGet, payload: map[string]any{"session_id": "s", "path": "a.go", "ref": "HEAD", "repo": "repo"}, invoke: (*WorkspaceFileHandlers).wsGetFileContentAtRef, assertReq: expectWorkspaceQuery("ref", "HEAD")},
|
||||
{name: "update", action: ws.ActionWorkspaceFileContentUpdate, path: "/api/v1/workspace/file/content", method: http.MethodPost, payload: map[string]any{"session_id": "s", "path": "a.go", "diff": "patch", "original_hash": "old", "repo": "repo"}, invoke: (*WorkspaceFileHandlers).wsUpdateFileContent, assertReq: expectWorkspaceJSON("diff", "patch")},
|
||||
{name: "create", action: ws.ActionWorkspaceFileCreate, path: "/api/v1/workspace/file/create", method: http.MethodPost, payload: map[string]any{"session_id": "s", "path": "new.go", "repo": "repo"}, invoke: (*WorkspaceFileHandlers).wsCreateFile, assertReq: expectWorkspaceJSON("path", "new.go")},
|
||||
{name: "delete", action: ws.ActionWorkspaceFileDelete, path: "/api/v1/workspace/file", method: http.MethodDelete, payload: map[string]any{"session_id": "s", "path": "old.go", "repo": "repo"}, invoke: (*WorkspaceFileHandlers).wsDeleteFile, assertReq: expectWorkspaceQuery("path", "old.go")},
|
||||
{name: "file search defaults limit", action: ws.ActionWorkspaceFilesSearch, path: "/api/v1/workspace/search", method: http.MethodGet, payload: map[string]any{"session_id": "s", "query": "go"}, invoke: (*WorkspaceFileHandlers).wsSearchFiles, assertReq: expectWorkspaceQuery("limit", "20")},
|
||||
{name: "content search", action: ws.ActionWorkspaceContentSearch, path: "/api/v1/workspace/content-search", method: http.MethodGet, payload: map[string]any{"session_id": "s", "query": "needle", "limit_per_repo": 7}, invoke: (*WorkspaceFileHandlers).wsSearchContent, assertReq: expectWorkspaceQuery("limit_per_repo", "7")},
|
||||
{name: "rename", action: ws.ActionWorkspaceFileRename, path: "/api/v1/workspace/file/rename", method: http.MethodPost, payload: map[string]any{"session_id": "s", "old_path": "a", "new_path": "b", "repo": "repo"}, invoke: (*WorkspaceFileHandlers).wsRenameFile, assertReq: expectWorkspaceJSON("new_path", "b")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := workspaceHandlerServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != tt.path || r.Method != tt.method {
|
||||
t.Errorf("request = %s %s, want %s %s", r.Method, r.URL.Path, tt.method, tt.path)
|
||||
}
|
||||
tt.assertReq(t, r)
|
||||
_, _ = w.Write([]byte(`{"success":true,"path":"a.go","content":"body","results":[],"matches":[]}`))
|
||||
})
|
||||
msg, _ := ws.NewRequest("id", tt.action, tt.payload)
|
||||
response, err := tt.invoke(h, context.Background(), msg)
|
||||
if err != nil || response == nil {
|
||||
t.Fatalf("response = (%s, %v)", response.Payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceFileHandlersMapDependencyFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
payload any
|
||||
invoke func(*WorkspaceFileHandlers, context.Context, *ws.Message) (*ws.Message, error)
|
||||
}{
|
||||
{name: "tree", action: ws.ActionWorkspaceFileTreeGet, payload: map[string]any{"session_id": "s"}, invoke: (*WorkspaceFileHandlers).wsGetFileTree},
|
||||
{name: "content", action: ws.ActionWorkspaceFileContentGet, payload: map[string]any{"session_id": "s", "path": "a"}, invoke: (*WorkspaceFileHandlers).wsGetFileContent},
|
||||
{name: "content at ref", action: ws.ActionWorkspaceFileContentGetRef, payload: map[string]any{"session_id": "s", "path": "a", "ref": "HEAD"}, invoke: (*WorkspaceFileHandlers).wsGetFileContentAtRef},
|
||||
{name: "update", action: ws.ActionWorkspaceFileContentUpdate, payload: map[string]any{"session_id": "s", "path": "a", "diff": "patch"}, invoke: (*WorkspaceFileHandlers).wsUpdateFileContent},
|
||||
{name: "create", action: ws.ActionWorkspaceFileCreate, payload: map[string]any{"session_id": "s", "path": "a"}, invoke: (*WorkspaceFileHandlers).wsCreateFile},
|
||||
{name: "delete", action: ws.ActionWorkspaceFileDelete, payload: map[string]any{"session_id": "s", "path": "a"}, invoke: (*WorkspaceFileHandlers).wsDeleteFile},
|
||||
{name: "search", action: ws.ActionWorkspaceFilesSearch, payload: map[string]any{"session_id": "s"}, invoke: (*WorkspaceFileHandlers).wsSearchFiles},
|
||||
{name: "content search", action: ws.ActionWorkspaceContentSearch, payload: map[string]any{"session_id": "s"}, invoke: (*WorkspaceFileHandlers).wsSearchContent},
|
||||
{name: "rename", action: ws.ActionWorkspaceFileRename, payload: map[string]any{"session_id": "s", "old_path": "a", "new_path": "b"}, invoke: (*WorkspaceFileHandlers).wsRenameFile},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := workspaceHandlerServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`{"error":"dependency failed"}`))
|
||||
})
|
||||
msg, _ := ws.NewRequest("id", tt.action, tt.payload)
|
||||
response, err := tt.invoke(h, context.Background(), msg)
|
||||
if err != nil || response == nil {
|
||||
t.Fatalf("response = (%#v, %v)", response, err)
|
||||
}
|
||||
assertWorkspaceContentSearchErrorCode(t, response, ws.ErrorCodeInternalError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceHandlerServer(t *testing.T, handler http.HandlerFunc) *WorkspaceFileHandlers {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
u, _ := url.Parse(server.URL)
|
||||
port, _ := strconv.Atoi(u.Port())
|
||||
client := agentctl.NewClient(u.Hostname(), port, newTestLogger())
|
||||
execution := &lifecycle.AgentExecution{SessionID: "s"}
|
||||
execution.SetAgentCtlClientForTesting(client)
|
||||
lookup := &mockExecutionLookup{executions: map[string]*lifecycle.AgentExecution{"s": execution}}
|
||||
return NewWorkspaceFileHandlers(lookup, newTestLogger())
|
||||
}
|
||||
|
||||
func expectWorkspaceQuery(key, want string) func(*testing.T, *http.Request) {
|
||||
return func(t *testing.T, r *http.Request) {
|
||||
t.Helper()
|
||||
if got := r.URL.Query().Get(key); got != want {
|
||||
t.Fatalf("query %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func expectWorkspaceJSON(key string, want any) func(*testing.T, *http.Request) {
|
||||
return func(t *testing.T, r *http.Request) {
|
||||
t.Helper()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if body[key] != want {
|
||||
t.Fatalf("body[%s] = %#v, want %#v", key, body[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceFileHandlersRegisterContentSearch(t *testing.T) {
|
||||
handler := NewWorkspaceFileHandlers(nil, newTestLogger())
|
||||
dispatcher := ws.NewDispatcher()
|
||||
|
||||
@@ -18,10 +18,6 @@
|
||||
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
|
||||
"path": "apps/backend/internal/agent/handlers/passthrough_handlers.go"
|
||||
},
|
||||
{
|
||||
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
|
||||
"path": "apps/backend/internal/agent/handlers/port_handlers.go"
|
||||
},
|
||||
{
|
||||
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
|
||||
"path": "apps/backend/internal/agent/handlers/review_change_source.go"
|
||||
@@ -30,18 +26,10 @@
|
||||
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
|
||||
"path": "apps/backend/internal/agent/handlers/shell_handlers.go"
|
||||
},
|
||||
{
|
||||
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
|
||||
"path": "apps/backend/internal/agent/handlers/vscode_handlers.go"
|
||||
},
|
||||
{
|
||||
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
|
||||
"path": "apps/backend/internal/agent/handlers/workspace_file_handlers.go"
|
||||
},
|
||||
{
|
||||
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
|
||||
"path": "apps/backend/internal/agent/handlers/workspace_file_handlers.go"
|
||||
},
|
||||
{
|
||||
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
|
||||
"path": "apps/backend/internal/agent/hostutility/manager.go"
|
||||
|
||||
Reference in New Issue
Block a user