|
6 | 6 | "fmt" |
7 | 7 | "net" |
8 | 8 | "net/http" |
| 9 | + "net/http/httptest" |
9 | 10 | "os" |
10 | 11 | "path/filepath" |
11 | 12 | "strings" |
@@ -614,7 +615,217 @@ func TestServe_E2E_WebSocketPipeline(t *testing.T) { |
614 | 615 | } |
615 | 616 | } |
616 | 617 |
|
617 | | - // Read deadline means we missed events |
618 | | - t.Error("E2E: did not complete message exchange before deadline") |
| 618 | +} |
| 619 | + |
| 620 | +// ── Full-Stack E2E with Mock LLM ────────────────────────────────────── |
| 621 | +// |
| 622 | +// Starts the real kode serve handler with a mock LLM backend and verifies |
| 623 | +// the complete WebUI flow: WS upgrade, prompt send, streaming events, |
| 624 | +// tool calls, and done signal — all without needing a real API key. |
| 625 | + |
| 626 | +func TestServe_E2E_FullWebUIFlow(t *testing.T) { |
| 627 | + // 1. Mock LLM server: simulates OpenAI chat completions API |
| 628 | + callCount := 0 |
| 629 | + llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 630 | + callCount++ |
| 631 | + w.Header().Set("Content-Type", "application/json") |
| 632 | + if callCount <= 2 { |
| 633 | + // First two calls: tool call (shell echo) |
| 634 | + fmt.Fprintf(w, `{"choices":[{"message":{"content":"Running step %d.","tool_calls":[{"id":"call_%d","function":{"name":"shell","arguments":"{\"command\":\"echo step %d\"}"}}]}}]}`, |
| 635 | + callCount, callCount, callCount) |
| 636 | + } else { |
| 637 | + // Final call: text response |
| 638 | + w.Write([]byte(`{"choices":[{"message":{"content":"All done."}}]}`)) |
| 639 | + } |
| 640 | + })) |
| 641 | + defer llmSrv.Close() |
| 642 | + |
| 643 | + // 2. Set up env for mock LLM |
| 644 | + envCleanup := setTestEnv(t, llmSrv.URL) |
| 645 | + defer envCleanup() |
| 646 | + |
| 647 | + // 3. Create a session store (isolated temp dir) |
| 648 | + store := newTestSessionStore(t) |
| 649 | + |
| 650 | + // 4. Build the real kode serve mux with mock config |
| 651 | + ln, mux := buildServeMux(t, store) |
| 652 | + defer ln.Close() |
| 653 | + |
| 654 | + // 5. Start serving |
| 655 | + errCh := make(chan error, 1) |
| 656 | + go func() { |
| 657 | + errCh <- serveOnListener(ln, mux) |
| 658 | + }() |
| 659 | + |
| 660 | + // 6. Wait for HTTP ready |
| 661 | + waitForHTTP(t, ln.Addr().String()) |
| 662 | + |
| 663 | + // 7. Connect via WebSocket |
| 664 | + wsURL := "ws://" + ln.Addr().String() + "/ws" |
| 665 | + conn, err := golangws.Dial(wsURL, "", "http://localhost") |
| 666 | + if err != nil { |
| 667 | + t.Fatalf("Dial(%q): %v", wsURL, err) |
| 668 | + } |
| 669 | + defer conn.Close() |
| 670 | + t.Log("✅ WebSocket connected") |
| 671 | + |
| 672 | + // 8. Send a prompt |
| 673 | + prompt := map[string]string{"type": "prompt", "content": "Hi"} |
| 674 | + payload, _ := json.Marshal(prompt) |
| 675 | + if err := golangws.Message.Send(conn, string(payload)); err != nil { |
| 676 | + t.Fatalf("Send: %v", err) |
| 677 | + } |
| 678 | + t.Log("✅ Prompt sent") |
| 679 | + |
| 680 | + // 9. Collect all events with timeout |
| 681 | + conn.SetReadDeadline(time.Now().Add(30 * time.Second)) |
| 682 | + |
| 683 | + var events []map[string]any |
| 684 | + var sawSession, sawToken, sawToolCall, sawDone bool |
| 685 | + |
| 686 | + for i := 0; i < 20; i++ { |
| 687 | + var raw []byte |
| 688 | + if err := golangws.Message.Receive(conn, &raw); err != nil { |
| 689 | + t.Fatalf("Receive event %d: %v (collected %d events)", i, err, len(events)) |
| 690 | + } |
| 691 | + t.Logf(" event[%d]: %s", i, string(raw)) |
| 692 | + |
| 693 | + var evt map[string]any |
| 694 | + if err := json.Unmarshal(raw, &evt); err != nil { |
| 695 | + t.Fatalf("unmarshal event %d: %v", i, err) |
| 696 | + } |
| 697 | + events = append(events, evt) |
| 698 | + |
| 699 | + switch evt["type"] { |
| 700 | + case "session": |
| 701 | + sawSession = true |
| 702 | + case "token": |
| 703 | + sawToken = true |
| 704 | + case "tool_call": |
| 705 | + sawToolCall = true |
| 706 | + case "tool_result": |
| 707 | + // expected after tool_call |
| 708 | + case "done": |
| 709 | + sawDone = true |
| 710 | + goto done // break out of loop |
| 711 | + case "error": |
| 712 | + t.Fatalf("unexpected error event: %v", evt["message"]) |
| 713 | + } |
| 714 | + } |
| 715 | + |
| 716 | +done: |
| 717 | + // 10. Validate event sequence |
| 718 | + if !sawSession { |
| 719 | + t.Fatal("E2E: missing 'session' event") |
| 720 | + } |
| 721 | + if !sawToken { |
| 722 | + t.Fatal("E2E: missing 'token' event (LLM response)") |
| 723 | + } |
| 724 | + if !sawToolCall { |
| 725 | + t.Fatal("E2E: missing 'tool_call' event") |
| 726 | + } |
| 727 | + if !sawDone { |
| 728 | + t.Fatal("E2E: missing 'done' event") |
| 729 | + } |
| 730 | + |
| 731 | + // Validate ordering: session must be first |
| 732 | + if events[0]["type"] != "session" { |
| 733 | + t.Errorf("E2E: first event type = %v, want 'session'", events[0]["type"]) |
| 734 | + } |
| 735 | + |
| 736 | + // Log the full sequence for inspection |
| 737 | + types := make([]string, len(events)) |
| 738 | + for i, e := range events { |
| 739 | + types[i] = e["type"].(string) |
| 740 | + } |
| 741 | + t.Logf("✅ E2E event sequence: %v", types) |
| 742 | + t.Log("✅ Full WebUI pipeline verified: upgrade → prompt → session → tokens → tool_call → done") |
| 743 | +} |
| 744 | + |
| 745 | +// ── E2E Helpers ──────────────────────────────────────────────────────── |
| 746 | + |
| 747 | +// setTestEnv configures env vars for testing with a mock LLM. |
| 748 | +// Returns a cleanup function that restores original values. |
| 749 | +func setTestEnv(t *testing.T, llmBaseURL string) func() { |
| 750 | + t.Helper() |
| 751 | + origDS := os.Getenv("DEEPSEEK_API_KEY") |
| 752 | + origOAI := os.Getenv("OPENAI_API_KEY") |
| 753 | + origKBS := os.Getenv("KODE_BASE_URL") |
| 754 | + origHome := os.Getenv("HOME") |
| 755 | + |
| 756 | + os.Setenv("DEEPSEEK_API_KEY", "sk-mock") |
| 757 | + os.Unsetenv("OPENAI_API_KEY") |
| 758 | + os.Setenv("KODE_BASE_URL", llmBaseURL) |
| 759 | + os.Setenv("HOME", t.TempDir()) |
| 760 | + |
| 761 | + return func() { |
| 762 | + os.Setenv("DEEPSEEK_API_KEY", origDS) |
| 763 | + os.Setenv("OPENAI_API_KEY", origOAI) |
| 764 | + os.Setenv("KODE_BASE_URL", origKBS) |
| 765 | + os.Setenv("HOME", origHome) |
| 766 | + } |
| 767 | +} |
| 768 | + |
| 769 | +// newTestSessionStore creates a session.Store backed by a temp directory. |
| 770 | +func newTestSessionStore(t *testing.T) *session.Store { |
| 771 | + t.Helper() |
| 772 | + origHome := os.Getenv("HOME") |
| 773 | + os.Setenv("HOME", t.TempDir()) |
| 774 | + t.Cleanup(func() { os.Setenv("HOME", origHome) }) |
| 775 | + store, err := session.NewStore() |
| 776 | + if err != nil { |
| 777 | + t.Fatalf("session.NewStore: %v", err) |
| 778 | + } |
| 779 | + return store |
| 780 | +} |
| 781 | + |
| 782 | +// buildServeMux creates a listener on a random port and builds the |
| 783 | +// kode serve HTTP mux with a pre-configured session store. |
| 784 | +func buildServeMux(t *testing.T, store *session.Store) (net.Listener, *http.ServeMux) { |
| 785 | + t.Helper() |
| 786 | + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| 787 | + if err != nil { |
| 788 | + t.Fatalf("listen: %v", err) |
| 789 | + } |
| 790 | + |
| 791 | + resolved := config.LoadConfig(config.CLIFlags{}) |
| 792 | + systemMessage := resolved.System |
| 793 | + if systemMessage == "" { |
| 794 | + systemMessage = defaultSystem |
| 795 | + } |
| 796 | + |
| 797 | + cwd, _ := os.Getwd() |
| 798 | + home, _ := os.UserHomeDir() |
| 799 | + resourceReg := resource.NewRegistry( |
| 800 | + resource.NewFileResolver(cwd), |
| 801 | + resource.NewSessionResolver(filepath.Join(home, ".kode", "sessions")), |
| 802 | + ) |
| 803 | + |
| 804 | + mux := http.NewServeMux() |
| 805 | + mux.HandleFunc("/", handleStatic()) |
| 806 | + mux.Handle("/ws", &golangws.Server{ |
| 807 | + Handshake: func(*golangws.Config, *http.Request) error { return nil }, |
| 808 | + Handler: func(conn *golangws.Conn) { |
| 809 | + handleWS(store, resourceReg, resolved, systemMessage, conn) |
| 810 | + }, |
| 811 | + }) |
| 812 | + mux.HandleFunc("/api/resources", handleResourceSearch(resourceReg)) |
| 813 | + mux.HandleFunc("/api/sessions", handleSessionList(store)) |
| 814 | + |
| 815 | + return ln, mux |
| 816 | +} |
| 817 | + |
| 818 | +// waitForHTTP blocks until the HTTP server responds with 200 on GET /. |
| 819 | +func waitForHTTP(t *testing.T, addr string) { |
| 820 | + t.Helper() |
| 821 | + for i := 0; i < 20; i++ { |
| 822 | + time.Sleep(250 * time.Millisecond) |
| 823 | + resp, err := http.Get("http://" + addr + "/") |
| 824 | + if err == nil && resp.StatusCode == 200 { |
| 825 | + resp.Body.Close() |
| 826 | + return |
| 827 | + } |
| 828 | + } |
| 829 | + t.Fatal("server not ready after 5s") |
619 | 830 | } |
620 | 831 |
|
0 commit comments