// 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) } }