test(agent): increase handler coverage (#2546)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-12 02:13:37 +02:00
committed by GitHub
parent 1b26646e67
commit 45f1bdc8d2
13 changed files with 1338 additions and 23 deletions
@@ -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"