@@ -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