import { act, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { EventFrame } from "./protocol"; import ChatView from "./ChatView"; import type { SessionsStore } from "./store"; import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; import type { SessionListItem } from "./protocol"; let seq: number = 0; function ev(type: string, extra: Partial = {}): EventFrame { seq += 1; return { v: 1, sessionId: "s1", seq, ts: 0, type, ...extra } as EventFrame; } const sessions: SessionListItem[] = [ { id: "s1", name: "worker", cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: false, repo: null, startedAt: 0, online: true, lastEventAt: 1, }, ]; function makeStore(over: Partial = {}): SessionsStore { return { sessions, state: "open", spawnJobs: [], refresh: async () => undefined, subscribe: ( sessionId: string, onEvents: (events: EventFrame[]) => void, ): (() => void) => { currentSub = { sessionId, onEvents }; return () => { if (currentSub !== null && currentSub.sessionId === sessionId) currentSub = null; }; }, ...over, }; } interface ActiveSub { sessionId: string; onEvents: (events: EventFrame[]) => void; } let currentSub: ActiveSub | null = null; function push(events: EventFrame[]): void { act(() => currentSub?.onEvents(events)); } function renderChat( store: SessionsStore, path = "/s/s1", ): ReturnType { return render( } /> OTHER} /> , ); } const pushToast = vi.fn(); function historyEvents(): EventFrame[] { seq = 0; return [ ev("message_end", { message: { role: "user", id: "u1", text: "hello there", thinking: null, toolCalls: [], toolCallId: null, }, }), ev("agent_start"), ev("message_end", { message: { role: "assistant", id: "a1", text: "hi!", thinking: "hmm", toolCalls: [], toolCallId: null, }, }), ev("agent_settled"), ]; } beforeEach(() => { seq = 0; currentSub = null; pushToast.mockClear(); seedSettings(); }); afterEach(() => { vi.restoreAllMocks(); }); describe("ChatView", () => { it("renders history from REST on mount and subscribes to the WS stream", async () => { const fetchMock = mockFetchJson((url) => { if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents(); return []; }); const store = makeStore(); const { rerender } = renderChat(store); await screen.findByText("hello there"); expect(screen.getByText("hi!")).toBeInTheDocument(); await waitFor(() => expect(currentSub).not.toBeNull()); expect(currentSub?.sessionId).toBe("s1"); expect(fetchMock).toHaveBeenCalled(); // header shows session name + model + online dot expect(screen.getByText("worker")).toBeInTheDocument(); expect(screen.getByText("glm-5.3")).toBeInTheDocument(); expect(screen.getByTitle("online")).toBeInTheDocument(); rerender( } /> OTHER} /> , ); }); it("live delta streaming appends into a streaming bubble and ends it", async () => { mockFetchJson(() => []); renderChat(makeStore()); await waitFor(() => expect(currentSub).not.toBeNull()); push([ ev("message_start", { message: { role: "assistant", id: "a9", text: "", thinking: null, toolCalls: [], toolCallId: null, }, }), ]); push([ev("message_update", { delta: "Hel" })]); push([ev("message_update", { delta: "lo" })]); expect(screen.getByText("Hello")).toBeInTheDocument(); expect(screen.queryByLabelText("Send message")).toBeNull(); expect(screen.getByLabelText("Abort current run")).toBeInTheDocument(); push([ ev("message_end", { message: { role: "assistant", id: "a9", text: "Hello world", thinking: null, toolCalls: [], toolCallId: null, }, }), ev("agent_settled"), ]); expect(screen.getByText("Hello world")).toBeInTheDocument(); expect(screen.getByLabelText("Send message")).toBeInTheDocument(); }); it("typing indicator shows while busy with no open stream", async () => { mockFetchJson(() => []); const { container } = renderChat(makeStore()); await waitFor(() => expect(currentSub).not.toBeNull()); push([ev("agent_start")]); expect(container.querySelector(".typing")).not.toBeNull(); push([ev("agent_settled")]); expect(container.querySelector(".typing")).toBeNull(); }); it("409 on send toasts 'session offline'", async () => { let n = 0; mockFetchJson((_url, init) => { if (init?.method === "POST") { n += 1; return jsonResponse({ error: "session offline" }, n === 1 ? 409 : 200); } return []; }); renderChat(makeStore()); const ta = screen.getByLabelText("Message"); await userEvent.type(ta, "go"); await userEvent.click(screen.getByLabelText("Send message")); await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("session offline"), ); }); it("non-409 send failure toasts the error message", async () => { mockFetchJson((_url, init) => { if (init?.method === "POST") return jsonResponse({ error: "nope" }, 500); return []; }); renderChat(makeStore()); await userEvent.type(screen.getByLabelText("Message"), "go"); await userEvent.click(screen.getByLabelText("Send message")); await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope")); }); it("Enter sends, Shift+Enter adds a newline, send disabled while empty or sending", async () => { const fetchMock = mockFetchJson((_url, init) => init?.method === "POST" ? { ok: true } : [], ); renderChat(makeStore()); const ta = screen.getByLabelText("Message") as HTMLTextAreaElement; const send = screen.getByLabelText("Send message") as HTMLButtonElement; expect(send).toBeDisabled(); await userEvent.type(ta, "hello"); expect(send).not.toBeDisabled(); fireEvent.keyDown(ta, { key: "Enter", shiftKey: true }); fireEvent.click(send); await userEvent.clear(ta); fireEvent.keyDown(ta, { key: "Enter" }); // empty draft: send() early-returns await vi.waitFor(() => { const post = fetchMock.mock.calls.find( (c) => (c[1] as RequestInit | undefined)?.method === "POST", ); expect(post).toBeDefined(); expect((post?.[1] as RequestInit).body).toBe( JSON.stringify({ message: "hello" }), ); }); expect(ta.value).toBe(""); }); it("abort posts to the abort route", async () => { const fetchMock = mockFetchJson((_url, init) => init?.method === "POST" ? { ok: true } : [], ); renderChat(makeStore()); push([ev("agent_start")]); await userEvent.click(screen.getByLabelText("Abort current run")); await vi.waitFor(() => { const abortCall = fetchMock.mock.calls.find( (c) => (c[1] as RequestInit | undefined)?.method === "POST" && String(c[0]).endsWith("/abort"), ); expect(abortCall).toBeDefined(); }); }); it("abort failure toasts", async () => { mockFetchJson((url, init) => { if (init?.method === "POST" && url.endsWith("/abort")) return jsonResponse({ error: "abort failed" }, 500); return []; }); renderChat(makeStore()); push([ev("agent_start")]); await userEvent.click(screen.getByLabelText("Abort current run")); await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("abort failed"), ); }); it("history load failure shows the error page with a back link", async () => { mockFetchJson((url) => { if (url.includes("/events")) return jsonResponse({ error: "db gone" }, 500); return []; }); renderChat(makeStore()); expect( await screen.findByText(/Failed to load history: db gone/), ).toBeInTheDocument(); expect(screen.getByText("Back to sessions")).toBeInTheDocument(); }); it("refetches missed persisted events when the ws (re)opens", async () => { let after = ""; mockFetchJson((url) => { const m = /[?&]after=(\d+)/.exec(url); if (m !== null) after = m[1] ?? ""; if (url.includes("/events")) return [ ev("message_end", { message: { role: "user", id: "u2", text: "caught up", thinking: null, toolCalls: [], toolCallId: null, }, }), ]; return []; }); const store = makeStore({ state: "closed" }); const { rerender } = render( } /> , ); await screen.findByText("caught up"); expect(after).toBe("0"); // reconnect: state closed -> open triggers the after=N refetch act(() => { rerender( } /> , ); }); await vi.waitFor(() => expect(after).toBe("1")); }); it("task panel toggle and tool render in both the aside and mobile view", async () => { mockFetchJson((url) => (url.includes("/events") ? historyEvents() : [])); const { container } = renderChat(makeStore()); // running tool start with no end shows in the Working section await screen.findByText("hello there"); const toggle = screen.getByLabelText("Toggle task panel"); expect(toggle).toHaveAttribute("aria-expanded", "false"); await userEvent.click(toggle); expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute( "aria-expanded", "true", ); expect(container.querySelectorAll(".task-panel")).toHaveLength(2); // aside + mobile expect(screen.getAllByText("No tasks yet.")).toHaveLength(2); }); it("no session id param renders the empty page", () => { render( } /> CHAT} /> , ); expect(screen.getByText("No session selected.")).toBeInTheDocument(); }); });