diff --git a/apps/backend/internal/agent/handlers/git_handlers_test.go b/apps/backend/internal/agent/handlers/git_handlers_test.go index cd25818c1c..ee1b01c74b 100644 --- a/apps/backend/internal/agent/handlers/git_handlers_test.go +++ b/apps/backend/internal/agent/handlers/git_handlers_test.go @@ -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{} diff --git a/apps/backend/internal/agent/handlers/handlers_test.go b/apps/backend/internal/agent/handlers/handlers_test.go new file mode 100644 index 0000000000..658b90853d --- /dev/null +++ b/apps/backend/internal/agent/handlers/handlers_test.go @@ -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) + } +} diff --git a/apps/backend/internal/agent/handlers/passthrough_handlers.go b/apps/backend/internal/agent/handlers/passthrough_handlers.go index ef43e9bfc4..a73d00d204 100644 --- a/apps/backend/internal/agent/handlers/passthrough_handlers.go +++ b/apps/backend/internal/agent/handlers/passthrough_handlers.go @@ -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")), diff --git a/apps/backend/internal/agent/handlers/passthrough_handlers_test.go b/apps/backend/internal/agent/handlers/passthrough_handlers_test.go new file mode 100644 index 0000000000..0379badd0e --- /dev/null +++ b/apps/backend/internal/agent/handlers/passthrough_handlers_test.go @@ -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) + } + }) + } +} diff --git a/apps/backend/internal/agent/handlers/port_handlers.go b/apps/backend/internal/agent/handlers/port_handlers.go index f20325b0bd..7ff29781ef 100644 --- a/apps/backend/internal/agent/handlers/port_handlers.go +++ b/apps/backend/internal/agent/handlers/port_handlers.go @@ -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, diff --git a/apps/backend/internal/agent/handlers/port_handlers_test.go b/apps/backend/internal/agent/handlers/port_handlers_test.go new file mode 100644 index 0000000000..976193ee4b --- /dev/null +++ b/apps/backend/internal/agent/handlers/port_handlers_test.go @@ -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 +} diff --git a/apps/backend/internal/agent/handlers/review_change_source_test.go b/apps/backend/internal/agent/handlers/review_change_source_test.go new file mode 100644 index 0000000000..f077786781 --- /dev/null +++ b/apps/backend/internal/agent/handlers/review_change_source_test.go @@ -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) +} diff --git a/apps/backend/internal/agent/handlers/shell_handlers_test.go b/apps/backend/internal/agent/handlers/shell_handlers_test.go index d1026d1539..3aa6652696 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers_test.go +++ b/apps/backend/internal/agent/handlers/shell_handlers_test.go @@ -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) diff --git a/apps/backend/internal/agent/handlers/vscode_handlers.go b/apps/backend/internal/agent/handlers/vscode_handlers.go index 22f6b4dce2..0df5adcb92 100644 --- a/apps/backend/internal/agent/handlers/vscode_handlers.go +++ b/apps/backend/internal/agent/handlers/vscode_handlers.go @@ -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, diff --git a/apps/backend/internal/agent/handlers/vscode_handlers_test.go b/apps/backend/internal/agent/handlers/vscode_handlers_test.go index c354e1c38c..071d747bb3 100644 --- a/apps/backend/internal/agent/handlers/vscode_handlers_test.go +++ b/apps/backend/internal/agent/handlers/vscode_handlers_test.go @@ -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) diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go index 87916edf9e..9c5619aab1 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go @@ -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")), diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers_test.go b/apps/backend/internal/agent/handlers/workspace_file_handlers_test.go index b8b4a21600..336d82819f 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers_test.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers_test.go @@ -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() diff --git a/config/architecture-lint/runtime_import.json b/config/architecture-lint/runtime_import.json index d07baa6c57..a9f0ce0f68 100644 --- a/config/architecture-lint/runtime_import.json +++ b/config/architecture-lint/runtime_import.json @@ -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"