Files
kandev/apps/backend/internal/gateway/websocket/lsp_handler_test.go
T
2026-08-05 10:57:24 +01:00

417 lines
13 KiB
Go

package websocket
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
gorillaws "github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/agentruntime"
"github.com/kandev/kandev/internal/lsp/installer"
"github.com/kandev/kandev/internal/lsp/protocol"
"github.com/kandev/kandev/internal/user/models"
)
type staticLSPUserService struct {
settings *models.UserSettings
}
type recordingLSPLifecycleManager struct {
runtimeName agentruntime.Runtime
execution *lifecycle.AgentExecution
resolveErr error
ensureErr error
ensureCalls int
}
func (m *recordingLSPLifecycleManager) ResolveSessionRuntime(context.Context, string) (agentruntime.Runtime, error) {
return m.runtimeName, m.resolveErr
}
func (m *recordingLSPLifecycleManager) GetOrEnsureExecution(context.Context, string) (*lifecycle.AgentExecution, error) {
m.ensureCalls++
return m.execution, m.ensureErr
}
type recordingLSPMessageWriter struct {
deadline time.Time
deadlineOnWrite time.Time
messageType int
payload []byte
}
func (w *recordingLSPMessageWriter) SetWriteDeadline(deadline time.Time) error {
w.deadline = deadline
return nil
}
func (w *recordingLSPMessageWriter) WriteMessage(messageType int, payload []byte) error {
w.deadlineOnWrite = w.deadline
w.messageType = messageType
w.payload = payload
return nil
}
func (s staticLSPUserService) GetUserSettings(context.Context) (*models.UserSettings, error) {
return s.settings, nil
}
func TestReadLSPMessage_Valid(t *testing.T) {
body := `{"jsonrpc":"2.0","id":1,"method":"initialize"}`
raw := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body)
reader := bufio.NewReader(strings.NewReader(raw))
msg, err := protocol.ReadMessage(reader)
if err != nil {
t.Fatalf("readLSPMessage() error = %v", err)
}
if string(msg) != body {
t.Errorf("readLSPMessage() = %q, want %q", string(msg), body)
}
}
func TestReadLSPMessage_WithExtraHeaders(t *testing.T) {
body := `{"jsonrpc":"2.0","id":2}`
raw := fmt.Sprintf("Content-Length: %d\r\nContent-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n%s", len(body), body)
reader := bufio.NewReader(strings.NewReader(raw))
msg, err := protocol.ReadMessage(reader)
if err != nil {
t.Fatalf("readLSPMessage() error = %v", err)
}
if string(msg) != body {
t.Errorf("readLSPMessage() = %q, want %q", string(msg), body)
}
}
func TestReadLSPMessage_MissingContentLength(t *testing.T) {
raw := "Content-Type: application/json\r\n\r\n{}"
reader := bufio.NewReader(strings.NewReader(raw))
_, err := protocol.ReadMessage(reader)
if err == nil {
t.Fatal("readLSPMessage() should return error for missing Content-Length")
}
if !strings.Contains(err.Error(), "missing Content-Length") {
t.Errorf("readLSPMessage() error = %q, want 'missing Content-Length'", err.Error())
}
}
func TestReadLSPMessage_InvalidContentLength(t *testing.T) {
raw := "Content-Length: abc\r\n\r\n{}"
reader := bufio.NewReader(strings.NewReader(raw))
_, err := protocol.ReadMessage(reader)
if err == nil {
t.Fatal("readLSPMessage() should return error for invalid Content-Length")
}
if !strings.Contains(err.Error(), "invalid Content-Length") {
t.Errorf("readLSPMessage() error = %q, want 'invalid Content-Length'", err.Error())
}
}
func TestReadLSPMessage_EOF(t *testing.T) {
reader := bufio.NewReader(bytes.NewReader(nil))
_, err := protocol.ReadMessage(reader)
if err == nil {
t.Fatal("readLSPMessage() should return error on EOF")
}
if err != io.EOF {
t.Errorf("readLSPMessage() error = %v, want io.EOF", err)
}
}
func TestReadLSPMessage_MultipleMessages(t *testing.T) {
body1 := `{"jsonrpc":"2.0","id":1}`
body2 := `{"jsonrpc":"2.0","id":2}`
raw := fmt.Sprintf("Content-Length: %d\r\n\r\n%sContent-Length: %d\r\n\r\n%s",
len(body1), body1, len(body2), body2)
reader := bufio.NewReader(strings.NewReader(raw))
msg1, err := protocol.ReadMessage(reader)
if err != nil {
t.Fatalf("first readLSPMessage() error = %v", err)
}
if string(msg1) != body1 {
t.Errorf("first readLSPMessage() = %q, want %q", string(msg1), body1)
}
msg2, err := protocol.ReadMessage(reader)
if err != nil {
t.Fatalf("second readLSPMessage() error = %v", err)
}
if string(msg2) != body2 {
t.Errorf("second readLSPMessage() = %q, want %q", string(msg2), body2)
}
}
func TestLspCommand_ViaRegistry(t *testing.T) {
tests := []struct {
language string
wantBinary string
wantArgs []string
}{
{"typescript", "typescript-language-server", []string{"--stdio"}},
{"go", "gopls", []string{"serve"}},
{"rust", "rust-analyzer", nil},
{"python", "pyright-langserver", []string{"--stdio"}},
{"kotlin", "kotlin-lsp", []string{"--stdio"}},
{"unknown", "", nil},
}
for _, tc := range tests {
binary, args := installer.LspCommand(tc.language)
if binary != tc.wantBinary {
t.Errorf("LspCommand(%q) binary = %q, want %q", tc.language, binary, tc.wantBinary)
}
if len(args) != len(tc.wantArgs) {
t.Errorf("LspCommand(%q) args = %v, want %v", tc.language, args, tc.wantArgs)
continue
}
for i := range args {
if args[i] != tc.wantArgs[i] {
t.Errorf("LspCommand(%q) args[%d] = %q, want %q", tc.language, i, args[i], tc.wantArgs[i])
}
}
}
}
func TestIsValidLSPLanguage_ViaRegistry(t *testing.T) {
tests := []struct {
language string
want bool
}{
{"typescript", true},
{"go", true},
{"rust", true},
{"python", true},
{"kotlin", true},
{"java", false},
{"", false},
{"ruby", false},
}
for _, tc := range tests {
if got := installer.IsSupported(tc.language); got != tc.want {
t.Errorf("IsSupported(%q) = %v, want %v", tc.language, got, tc.want)
}
}
}
func TestCloseCodeConstants(t *testing.T) {
// Verify close codes are in the expected range (4000-4999 for application-specific)
codes := []struct {
name string
code int
}{
{"lspCloseBinaryNotFound", lspCloseBinaryNotFound},
{"lspCloseSessionNotFound", lspCloseSessionNotFound},
{"lspCloseInstallFailed", lspCloseInstallFailed},
{"lspCloseUnsupportedExecutor", lspCloseUnsupportedExecutor},
{"lspCloseCapacityExceeded", lspCloseCapacityExceeded},
{"lspCloseStreamError", lspCloseStreamError},
}
for _, tc := range codes {
if tc.code < 4000 || tc.code > 4999 {
t.Errorf("%s = %d, want value in range 4000-4999", tc.name, tc.code)
}
}
// Verify they're distinct
seen := make(map[int]string)
for _, tc := range codes {
if prev, ok := seen[tc.code]; ok {
t.Errorf("%s and %s have the same code %d", prev, tc.name, tc.code)
}
seen[tc.code] = tc.name
}
}
func TestWriteLSPProxyMessageSetsDeadlineBeforeWriting(t *testing.T) {
writer := &recordingLSPMessageWriter{}
startedAt := time.Now()
if err := writeLSPProxyMessage(writer, gorillaws.TextMessage, []byte("payload")); err != nil {
t.Fatalf("writeLSPProxyMessage() error = %v", err)
}
if writer.deadlineOnWrite.IsZero() {
t.Fatal("WriteMessage() called without a write deadline")
}
if writer.deadlineOnWrite.Before(startedAt.Add(lspProxyWriteTimeout)) {
t.Fatalf("write deadline = %v, want at least %v", writer.deadlineOnWrite, startedAt.Add(lspProxyWriteTimeout))
}
}
func TestForwardLSPCloseUsesGenericStreamErrorCode(t *testing.T) {
writer := &recordingLSPMessageWriter{}
handler := &LSPHandler{}
handler.forwardLSPClose(writer, errors.New("transport failed"))
if writer.messageType != gorillaws.CloseMessage {
t.Fatalf("message type = %d, want close message", writer.messageType)
}
closeCode := int(writer.payload[0])<<8 | int(writer.payload[1])
if closeCode != lspCloseStreamError {
t.Fatalf("close code = %d, want %d", closeCode, lspCloseStreamError)
}
if writer.deadlineOnWrite.IsZero() {
t.Fatal("close frame written without a write deadline")
}
}
func TestForwardLSPClosePreservesTaskHostApplicationCode(t *testing.T) {
const autoInstallUnsupportedCode = 4007
writer := &recordingLSPMessageWriter{}
handler := &LSPHandler{}
handler.forwardLSPClose(writer, &gorillaws.CloseError{
Code: autoInstallUnsupportedCode,
Text: "auto-install unsupported on task host",
})
closeCode := int(writer.payload[0])<<8 | int(writer.payload[1])
if closeCode != autoInstallUnsupportedCode {
t.Fatalf("close code = %d, want %d", closeCode, autoInstallUnsupportedCode)
}
}
func TestLSPRuntimeSupported(t *testing.T) {
tests := []struct {
runtime agentruntime.Runtime
want bool
}{
{agentruntime.RuntimeStandalone, true},
{agentruntime.RuntimeDocker, true},
{agentruntime.RuntimeSprites, false},
{agentruntime.RuntimeRemoteDocker, false},
{agentruntime.RuntimeSSH, false},
}
for _, tc := range tests {
if got := lspRuntimeSupported(tc.runtime); got != tc.want {
t.Fatalf("lspRuntimeSupported(%q) = %v, want %v", tc.runtime, got, tc.want)
}
}
}
func TestResolveLSPExecutionRejectsUnsupportedRuntimeBeforeEnsure(t *testing.T) {
manager := &recordingLSPLifecycleManager{runtimeName: agentruntime.RuntimeSSH}
handler := &LSPHandler{lifecycleMgr: manager, capacity: newLSPCapacityLimiter(1)}
_, runtimeName, err := handler.resolveLSPExecution(context.Background(), "session-ssh")
if !errors.Is(err, errLSPUnsupportedExecutor) {
t.Fatalf("resolveLSPExecution() error = %v, want unsupported executor", err)
}
if runtimeName != agentruntime.RuntimeSSH {
t.Fatalf("resolveLSPExecution() runtime = %q, want %q", runtimeName, agentruntime.RuntimeSSH)
}
if manager.ensureCalls != 0 {
t.Fatalf("GetOrEnsureExecution calls = %d, want 0", manager.ensureCalls)
}
}
func TestResolveLSPExecutionEnsuresSupportedRuntime(t *testing.T) {
execution := &lifecycle.AgentExecution{RuntimeName: agentruntime.RuntimeDocker}
manager := &recordingLSPLifecycleManager{
runtimeName: agentruntime.RuntimeDocker,
execution: execution,
}
handler := &LSPHandler{lifecycleMgr: manager, capacity: newLSPCapacityLimiter(1)}
got, runtimeName, err := handler.resolveLSPExecution(context.Background(), "session-docker")
if err != nil {
t.Fatalf("resolveLSPExecution() error = %v", err)
}
if got != execution || runtimeName != agentruntime.RuntimeDocker {
t.Fatalf(
"resolveLSPExecution() = (%p, %q), want (%p, %q)",
got, runtimeName, execution, agentruntime.RuntimeDocker,
)
}
if manager.ensureCalls != 1 {
t.Fatalf("GetOrEnsureExecution calls = %d, want 1", manager.ensureCalls)
}
handler.capacity.Release()
}
func TestHandleLSPConnectionChecksCapacityBeforeEnsuringExecution(t *testing.T) {
gin.SetMode(gin.TestMode)
manager := &recordingLSPLifecycleManager{runtimeName: agentruntime.RuntimeStandalone}
capacity := newLSPCapacityLimiter(1)
if !capacity.TryAcquire() {
t.Fatal("failed to fill test capacity")
}
defer capacity.Release()
handler := &LSPHandler{
lifecycleMgr: manager,
capacity: capacity,
logger: testLogger(),
}
router := gin.New()
router.GET("/lsp/:sessionId", handler.HandleLSPConnection)
server := httptest.NewServer(router)
defer server.Close()
conn, _, err := gorillaws.DefaultDialer.Dial(
"ws"+strings.TrimPrefix(server.URL, "http")+"/lsp/cold-session?language=go",
nil,
)
if err != nil {
t.Fatalf("Dial() error = %v", err)
}
defer func() { _ = conn.Close() }()
if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
_, _, readErr := conn.ReadMessage()
var closeErr *gorillaws.CloseError
if !errors.As(readErr, &closeErr) || closeErr.Code != lspCloseCapacityExceeded {
t.Fatalf("ReadMessage() error = %v, want close code %d", readErr, lspCloseCapacityExceeded)
}
if manager.ensureCalls != 0 {
t.Fatalf("GetOrEnsureExecution calls = %d, want 0", manager.ensureCalls)
}
}
func TestResolveLSPExecutionReleasesCapacityWhenEnsureFails(t *testing.T) {
ensureErr := errors.New("task start failed")
manager := &recordingLSPLifecycleManager{
runtimeName: agentruntime.RuntimeStandalone,
ensureErr: ensureErr,
}
capacity := newLSPCapacityLimiter(1)
handler := &LSPHandler{lifecycleMgr: manager, capacity: capacity}
_, _, err := handler.resolveLSPExecution(context.Background(), "failed-session")
if !errors.Is(err, ensureErr) {
t.Fatalf("resolveLSPExecution() error = %v, want %v", err, ensureErr)
}
if !capacity.TryAcquire() {
t.Fatal("capacity slot was not released after ensure failure")
}
capacity.Release()
}
func TestShouldAutoInstallRejectsManualOnlyLanguage(t *testing.T) {
handler := &LSPHandler{userService: staticLSPUserService{settings: &models.UserSettings{
LspAutoInstallLanguages: []string{"kotlin", "python", "rust"},
}}}
if handler.shouldAutoInstall(context.Background(), "kotlin") {
t.Fatal("Kotlin must remain manual-install-only even if stale settings contain it")
}
if !handler.shouldAutoInstall(context.Background(), "python") {
t.Fatal("Python should honor its auto-install setting")
}
if !handler.shouldAutoInstall(context.Background(), "rust") {
t.Fatal("Rust preference must reach agentctl so the task host decides platform support")
}
}