From 69ebc1e59b759f81265e3ccf1fea496d4123a5a6 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 9 May 2026 15:00:38 +0200 Subject: [PATCH] fix(chat): vite proxy ws + e2e tests para chat WebSocket - frontend/vite.config.ts: anadir ws: true al proxy de /api para que el dev server (5180) reenvie WebSocket upgrade al backend (8095). Sin esto Firefox da "websocket error" al abrir /api/chat/ws en modo dev. - e2e/chat_ws_e2e_test.go: 4 tests nuevos que arrancan el binario kanban en puerto efimero con un fake claude (bash script que emite NDJSON), se loguean via /api/auth/login y dialean /api/chat/ws con cookie de sesion. Verifican: deltas + done, tool_use + tool_result + board_changed, rechazo sin sesion, /api/tool sin token = 401. - e2e/go.mod: anade nhooyr.io/websocket (cliente WS para tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/chat_ws_e2e_test.go | 353 ++++++++++++++++++++++++++++++++++++++++ e2e/go.mod | 5 +- e2e/go.sum | 2 + frontend/vite.config.ts | 6 +- 4 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 e2e/chat_ws_e2e_test.go create mode 100644 e2e/go.sum diff --git a/e2e/chat_ws_e2e_test.go b/e2e/chat_ws_e2e_test.go new file mode 100644 index 0000000..c87b4d1 --- /dev/null +++ b/e2e/chat_ws_e2e_test.go @@ -0,0 +1,353 @@ +// E2E test que arranca el binario kanban en un puerto efimero con un fake +// claude script y verifica que /api/chat/ws emite los eventos esperados. +// +// Cubre el bug del proxy Vite (ws: true) tambien en el camino directo: la +// conexion WebSocket se establece contra el puerto del backend y atraviesa +// el upgrade real de nhooyr.io/websocket. +// +// Ejecucion: +// cd apps/kanban/e2e && go test -v -run TestE2E_ChatWS ./... +// +// Build artifacts: el test compila el binario kanban en un dir temporal. +// El fake claude tambien se escribe en ese dir. +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "nhooyr.io/websocket" +) + +// buildKanbanBinary compila el backend en un binario temporal y retorna su +// path. Se compila una vez por test. +func buildKanbanBinary(t *testing.T) string { + t.Helper() + dir := t.TempDir() + bin := filepath.Join(dir, "kanban") + cmd := exec.Command("go", "build", "-tags", "fts5", "-o", bin, ".") + cmd.Dir = "../backend" + cmd.Env = append(os.Environ(), "CGO_ENABLED=1") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go build kanban: %v\n%s", err, out) + } + return bin +} + +// fakeClaudeScript writes a bash script that emits NDJSON stream-json events +// and exits 0. +func fakeClaudeBin(t *testing.T, payload string) string { + t.Helper() + if _, err := os.Stat("/bin/bash"); err != nil { + t.Skip("/bin/bash not available") + } + dir := t.TempDir() + path := filepath.Join(dir, "claude") + body := "#!/bin/bash\nset -e\ncat <<'__EOF__'\n" + payload + "\n__EOF__\n" + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatalf("write fake claude: %v", err) + } + return path +} + +// freePort returns a random unused TCP port. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +// kanbanLauncher starts kanban on a random port with a fake claude binary +// and a fresh DB. Returns the URL and a cleanup func. +type kanbanLauncher struct { + URL string + Port int + DBDir string + Cancel context.CancelFunc + Stderr *bytes.Buffer + Stdout *bytes.Buffer +} + +func startKanban(t *testing.T, fakeClaude string) *kanbanLauncher { + t.Helper() + bin := buildKanbanBinary(t) + dbDir := t.TempDir() + dbPath := filepath.Join(dbDir, "kanban.db") + port := freePort(t) + + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, bin, + "--port", fmt.Sprint(port), + "--db", dbPath, + "--initial-admin", "e2e:e2etest", + ) + cmd.Env = append(os.Environ(), + "KANBAN_CLAUDE_BIN="+fakeClaude, + "KANBAN_LISTEN_PORT="+fmt.Sprint(port), + "KANBAN_INITIAL_ADMIN=e2e:e2etest", + ) + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Start(); err != nil { + cancel() + t.Fatalf("start kanban: %v", err) + } + + l := &kanbanLauncher{ + URL: fmt.Sprintf("http://127.0.0.1:%d", port), + Port: port, + DBDir: dbDir, + Cancel: cancel, + Stdout: stdout, + Stderr: stderr, + } + + // Espera a que el server responda /api/board (publico-ish: 401 sin sesion). + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get(l.URL + "/api/board") + if err == nil { + resp.Body.Close() + t.Cleanup(func() { + cancel() + _ = cmd.Wait() + if t.Failed() { + t.Logf("kanban stdout:\n%s", stdout.String()) + t.Logf("kanban stderr:\n%s", stderr.String()) + } + }) + return l + } + time.Sleep(150 * time.Millisecond) + } + cancel() + _ = cmd.Wait() + t.Fatalf("kanban no respondio en 15s\nstdout:\n%s\nstderr:\n%s", stdout.String(), stderr.String()) + return nil +} + +// loginGetCookie performs API login and returns the cookie jar with session. +func loginGetCookie(t *testing.T, baseURL string) http.CookieJar { + t.Helper() + jar, _ := cookiejar.New(nil) + cli := &http.Client{Jar: jar, Timeout: 5 * time.Second} + body, _ := json.Marshal(map[string]string{"username": "e2e", "password": "e2etest"}) + resp, err := cli.Post(baseURL+"/api/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("login: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + buf, _ := io.ReadAll(resp.Body) + t.Fatalf("login status %d: %s", resp.StatusCode, buf) + } + return jar +} + +// dialWS opens a WebSocket using the session cookie from the jar. +func dialWS(t *testing.T, baseURL string, jar http.CookieJar) *websocket.Conn { + t.Helper() + u, _ := url.Parse(baseURL) + wsURL := "ws://" + u.Host + "/api/chat/ws" + hdr := http.Header{} + for _, c := range jar.Cookies(u) { + hdr.Add("Cookie", c.Name+"="+c.Value) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: hdr}) + if err != nil { + t.Fatalf("ws dial %s: %v", wsURL, err) + } + return conn +} + +type wsMsg struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Tool string `json:"tool,omitempty"` + ToolID string `json:"tool_id,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Result string `json:"result,omitempty"` + IsError bool `json:"is_error,omitempty"` + BoardChanged bool `json:"board_changed,omitempty"` + Error string `json:"error,omitempty"` +} + +func readMsg(t *testing.T, conn *websocket.Conn) wsMsg { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, data, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read: %v", err) + } + var m wsMsg + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal %q: %v", string(data), err) + } + return m +} + +func writeMsg(t *testing.T, conn *websocket.Conn, payload any) { + t.Helper() + body, _ := json.Marshal(payload) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := conn.Write(ctx, websocket.MessageText, body); err != nil { + t.Fatalf("write: %v", err) + } +} + +// --- Tests ---------------------------------------------------------------- + +// TestE2E_ChatWS_StreamsTextAndCloses arranca el backend con fake claude +// que emite un mensaje de texto + result, abre WebSocket via cookie de +// sesion y comprueba que llegan deltas + done. +func TestE2E_ChatWS_StreamsTextAndCloses(t *testing.T) { + payload := `{"type":"system","subtype":"init","session_id":"s1","model":"test"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hola "}]}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"mundo"}]}} +{"type":"result","subtype":"success","is_error":false,"result":"Hola mundo","stop_reason":"end_turn"}` + + srv := startKanban(t, fakeClaudeBin(t, payload)) + jar := loginGetCookie(t, srv.URL) + conn := dialWS(t, srv.URL, jar) + defer conn.Close(websocket.StatusNormalClosure, "") + + writeMsg(t, conn, map[string]any{ + "messages": []map[string]string{{"role": "user", "content": "saluda"}}, + }) + + var deltas []string + var sawDone bool + for i := 0; i < 12 && !sawDone; i++ { + m := readMsg(t, conn) + switch m.Type { + case "delta": + deltas = append(deltas, m.Text) + case "done": + sawDone = true + case "error": + t.Fatalf("error event: %s", m.Error) + } + } + if !sawDone { + t.Fatalf("never received done event") + } + if got := strings.Join(deltas, ""); got != "Hola mundo" { + t.Fatalf("expected 'Hola mundo', got %q", got) + } +} + +// TestE2E_ChatWS_ToolUseFlow comprueba que tool_use + tool_result se +// reenvian correctamente y que board_changed=true cuando la tool muta. +func TestE2E_ChatWS_ToolUseFlow(t *testing.T) { + payload := `{"type":"system","subtype":"init"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"mcp__kanban__create_column","input":{"name":"Backlog"}}]}} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"{\"ok\":true,\"result\":{\"id\":\"col_x\"}}","is_error":false}]}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Listo"}]}} +{"type":"result","subtype":"success","is_error":false,"result":"Listo","stop_reason":"end_turn"}` + + srv := startKanban(t, fakeClaudeBin(t, payload)) + jar := loginGetCookie(t, srv.URL) + conn := dialWS(t, srv.URL, jar) + defer conn.Close(websocket.StatusNormalClosure, "") + + writeMsg(t, conn, map[string]any{ + "messages": []map[string]string{{"role": "user", "content": "crea Backlog"}}, + }) + + var sawToolUse, sawToolResult, sawDelta, sawDone, boardChanged bool + var toolName string + for i := 0; i < 16 && !sawDone; i++ { + m := readMsg(t, conn) + switch m.Type { + case "tool_use": + sawToolUse = true + toolName = m.Tool + if !strings.Contains(string(m.Input), "Backlog") { + t.Errorf("input missing Backlog: %s", m.Input) + } + case "tool_result": + sawToolResult = true + case "delta": + sawDelta = true + case "done": + sawDone = true + boardChanged = m.BoardChanged + case "error": + t.Fatalf("error: %s", m.Error) + } + } + if !sawToolUse || !sawToolResult || !sawDelta || !sawDone { + t.Fatalf("missing events: tool_use=%v tool_result=%v delta=%v done=%v", + sawToolUse, sawToolResult, sawDelta, sawDone) + } + if toolName != "create_column" { + t.Errorf("expected tool stripped to create_column, got %q", toolName) + } + if !boardChanged { + t.Errorf("expected board_changed=true") + } +} + +// TestE2E_ChatWS_RejectsUnauthenticated: sin cookie de sesion el upgrade +// debe fallar con 401 (auth middleware /api/chat/ws). +func TestE2E_ChatWS_RejectsUnauthenticated(t *testing.T) { + payload := `{"type":"result","subtype":"success","is_error":false,"result":""}` + srv := startKanban(t, fakeClaudeBin(t, payload)) + + u, _ := url.Parse(srv.URL) + wsURL := "ws://" + u.Host + "/api/chat/ws" + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _, err := websocket.Dial(ctx, wsURL, nil) + if err == nil { + t.Fatalf("expected dial to fail without cookie") + } + if !strings.Contains(err.Error(), "401") { + t.Fatalf("expected 401 in error, got: %v", err) + } +} + +// TestE2E_InternalToolEndpoint_RealBackend: comprueba que el endpoint +// /api/tool/{name} esta vivo y rechaza tokens invalidos. Sirve para +// confirmar que la auth bypass + token check funciona en el binario +// real (no solo en httptest). +func TestE2E_InternalToolEndpoint_RealBackend(t *testing.T) { + payload := `{"type":"result","is_error":false,"result":""}` + srv := startKanban(t, fakeClaudeBin(t, payload)) + + // Sin token -> 401 + req, _ := http.NewRequest("POST", srv.URL+"/api/tool/list_board", strings.NewReader("{}")) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post: %v", err) + } + resp.Body.Close() + if resp.StatusCode != 401 { + t.Fatalf("expected 401 without token, got %d", resp.StatusCode) + } +} diff --git a/e2e/go.mod b/e2e/go.mod index a9ac9fe..8d314d0 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -2,6 +2,9 @@ module kanban-e2e go 1.25.0 -require fn-registry v0.0.0-00010101000000-000000000000 +require ( + fn-registry v0.0.0-00010101000000-000000000000 + nhooyr.io/websocket v1.8.17 +) replace fn-registry => ../../.. diff --git a/e2e/go.sum b/e2e/go.sum new file mode 100644 index 0000000..9c3072b --- /dev/null +++ b/e2e/go.sum @@ -0,0 +1,2 @@ +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7125d31..ac4529f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,7 +12,11 @@ export default defineConfig({ server: { port: 5180, proxy: { - "/api": "http://localhost:8095", + "/api": { + target: "http://localhost:8095", + ws: true, + changeOrigin: true, + }, }, }, build: {