Skip to content

Commit a76cea8

Browse files
committed
test: add E2E WebSocket pipeline integration test
Adds TestServe_E2E_WebSocketPipeline that starts the real kode serve handler (not a mock) and verifies the full WebSocket pipeline: - Upgrade (x/net Server handshake) - Message send/receive via golang.org/x/net/websocket - Agent creation and error response flow Refactors serveCmd to extract serveOnListener() so the test can pass a pre-created listener without a TOCTOU race on port allocation. Runs in CI without API key — proves upgrade + message exchange work even when the LLM call fails (expected error, not a WS failure).
1 parent 798016b commit a76cea8

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

cmd/kode/serve.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ Flags:
9898
openInBrowser("http://" + listener.Addr().String())
9999
}
100100

101+
return serveOnListener(listener, mux)
102+
}
103+
104+
// serveOnListener serves the kode Web UI on a pre-created listener.
105+
// Extracted for testing — allows E2E tests to pass a listener on a known port.
106+
func serveOnListener(listener net.Listener, mux *http.ServeMux) error {
101107
return http.Serve(listener, mux)
102108
}
103109

cmd/kode/serve_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@ import (
66
"fmt"
77
"net"
88
"net/http"
9+
"os"
10+
"path/filepath"
911
"strings"
1012
"testing"
1113
"time"
1214

15+
"github.com/BackendStack21/kode/internal/config"
16+
"github.com/BackendStack21/kode/internal/resource"
1317
"github.com/BackendStack21/kode/internal/session"
1418
golangws "golang.org/x/net/websocket"
1519
)
@@ -478,3 +482,139 @@ func TestServe_MultiplePrompts(t *testing.T) {
478482
}
479483
}
480484
}
485+
486+
// ── E2E Integration Test ───────────────────────────────────────────────
487+
//
488+
// Starts the real kode serve process and verifies the full WebSocket
489+
// pipeline: upgrade, message send, response receive.
490+
491+
func TestServe_E2E_WebSocketPipeline(t *testing.T) {
492+
// Create a listener on a random port — no TOCTOU race since we pass
493+
// the listener directly to serveOnListener.
494+
ln, err := net.Listen("tcp", "127.0.0.1:0")
495+
if err != nil {
496+
t.Fatalf("listen: %v", err)
497+
}
498+
addr := ln.Addr().String()
499+
500+
// Load config and build the mux (same setup as serveCmd)
501+
resolved := config.LoadConfig(config.CLIFlags{})
502+
systemMessage := resolved.System
503+
if systemMessage == "" {
504+
systemMessage = defaultSystem
505+
}
506+
507+
store, err := session.NewStore()
508+
if err != nil {
509+
t.Fatalf("session store: %v", err)
510+
}
511+
512+
cwd, _ := os.Getwd()
513+
home, _ := os.UserHomeDir()
514+
resourceReg := resource.NewRegistry(
515+
resource.NewFileResolver(cwd),
516+
resource.NewSessionResolver(filepath.Join(home, ".kode", "sessions")),
517+
)
518+
519+
mux := http.NewServeMux()
520+
mux.HandleFunc("/", handleStatic())
521+
mux.Handle("/ws", &golangws.Server{
522+
Handshake: func(*golangws.Config, *http.Request) error { return nil },
523+
Handler: func(conn *golangws.Conn) {
524+
handleWS(store, resourceReg, resolved, systemMessage, conn)
525+
},
526+
})
527+
mux.HandleFunc("/api/resources", handleResourceSearch(resourceReg))
528+
mux.HandleFunc("/api/sessions", handleSessionList(store))
529+
530+
// Start serving on the pre-created listener in a goroutine
531+
errCh := make(chan error, 1)
532+
go func() {
533+
errCh <- serveOnListener(ln, mux)
534+
}()
535+
defer ln.Close()
536+
537+
// Wait for server to be ready
538+
var httpReady bool
539+
for i := 0; i < 20; i++ {
540+
time.Sleep(250 * time.Millisecond)
541+
resp, err := http.Get("http://" + addr + "/")
542+
if err == nil && resp.StatusCode == 200 {
543+
resp.Body.Close()
544+
httpReady = true
545+
break
546+
}
547+
}
548+
if !httpReady {
549+
// Check if serveCmd returned an error immediately
550+
select {
551+
case err := <-errCh:
552+
t.Fatalf("server exited before ready: %v", err)
553+
default:
554+
t.Fatal("server not ready after 5s")
555+
}
556+
}
557+
defer ln.Close()
558+
559+
// 1. Connect via WebSocket
560+
wsURL := "ws://" + addr + "/ws"
561+
conn, err := golangws.Dial(wsURL, "", "http://localhost")
562+
if err != nil {
563+
t.Fatalf("Dial(%q): %v", wsURL, err)
564+
}
565+
defer conn.Close()
566+
t.Log("E2E: WebSocket connected")
567+
568+
// 2. Send a prompt
569+
msg := map[string]string{"type": "prompt", "content": "say hello"}
570+
payload, _ := json.Marshal(msg)
571+
if err := golangws.Message.Send(conn, string(payload)); err != nil {
572+
t.Fatalf("Send: %v", err)
573+
}
574+
t.Log("E2E: Prompt sent")
575+
576+
// 3. Read response — we expect at minimum a 'session' event
577+
// followed by either a token/error/done event.
578+
// This proves the full WS pipeline: upgrade → handler → agent → response.
579+
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
580+
581+
var sawSession bool
582+
for i := 0; i < 5; i++ {
583+
var data []byte
584+
if err := golangws.Message.Receive(conn, &data); err != nil {
585+
t.Fatalf("Receive event %d: %v", i, err)
586+
}
587+
t.Logf("E2E event %d: %s", i, string(data))
588+
589+
var evt map[string]any
590+
if err := json.Unmarshal(data, &evt); err != nil {
591+
t.Fatalf("unmarshal event %d: %v", i, err)
592+
}
593+
594+
switch evt["type"] {
595+
case "session":
596+
sawSession = true
597+
if sid, ok := evt["session_id"].(string); ok && sid != "" {
598+
t.Logf("E2E: session %s created", sid)
599+
}
600+
case "token", "thinking", "tool_call", "tool_result":
601+
// Normal streaming events — pipeline is working
602+
case "done":
603+
if !sawSession {
604+
t.Error("E2E: got 'done' before 'session' event")
605+
}
606+
return // success
607+
case "error":
608+
// Two possible flows:
609+
// 1. Agent setup fails (no API key) → error before session
610+
// 2. LLM call fails (bad API key) → session then error
611+
// Both prove the WS pipeline works.
612+
t.Logf("E2E: expected error (no API key in test env): %s", evt["message"])
613+
return // success — WS pipeline proven
614+
}
615+
}
616+
617+
// Read deadline means we missed events
618+
t.Error("E2E: did not complete message exchange before deadline")
619+
}
620+

0 commit comments

Comments
 (0)