import { act, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "../api/client"; import type { Message } from "../types"; import { Chat } from "./Chat"; // Module-level store for the WebSocket handlers captured during connect(). // Tests in the "message rendering" suite use this to simulate incoming messages. type WsHandlers = { onToken: (content: string) => void; onUpdate: (history: Message[]) => void; onSessionId: (sessionId: string) => void; onError: (message: string) => void; onActivity: (toolName: string) => void; onReconciliationProgress: ( storyId: string, status: string, message: string, ) => void; }; let capturedWsHandlers: WsHandlers | null = null; vi.mock("../api/client", () => { const api = { getOllamaModels: vi.fn(), getAnthropicApiKeyExists: vi.fn(), getAnthropicModels: vi.fn(), getModelPreference: vi.fn(), setModelPreference: vi.fn(), cancelChat: vi.fn(), setAnthropicApiKey: vi.fn(), }; class ChatWebSocket { connect(handlers: WsHandlers) { capturedWsHandlers = handlers; } close() {} sendChat() {} cancel() {} } return { api, ChatWebSocket }; }); const mockedApi = { getOllamaModels: vi.mocked(api.getOllamaModels), getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists), getAnthropicModels: vi.mocked(api.getAnthropicModels), getModelPreference: vi.mocked(api.getModelPreference), setModelPreference: vi.mocked(api.setModelPreference), cancelChat: vi.mocked(api.cancelChat), setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey), }; function setupMocks() { mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]); mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true); mockedApi.getAnthropicModels.mockResolvedValue([]); mockedApi.getModelPreference.mockResolvedValue(null); mockedApi.setModelPreference.mockResolvedValue(true); mockedApi.cancelChat.mockResolvedValue(true); mockedApi.setAnthropicApiKey.mockResolvedValue(true); } describe("Chat message rendering — unified tool call UI", () => { beforeEach(() => { capturedWsHandlers = null; setupMocks(); }); it("renders tool call badge for assistant message with tool_calls (AC3)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const messages: Message[] = [ { role: "user", content: "Read src/main.rs" }, { role: "assistant", content: "I'll read that file.", tool_calls: [ { id: "toolu_abc", type: "function", function: { name: "Read", arguments: '{"file_path":"src/main.rs"}', }, }, ], }, ]; act(() => { capturedWsHandlers?.onUpdate(messages); }); expect(await screen.findByText("I'll read that file.")).toBeInTheDocument(); expect(await screen.findByText("Read(src/main.rs)")).toBeInTheDocument(); }); it("renders collapsible tool output for tool role messages (AC3)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const messages: Message[] = [ { role: "user", content: "Check the file" }, { role: "assistant", content: "", tool_calls: [ { id: "toolu_1", type: "function", function: { name: "Read", arguments: '{"file_path":"foo.rs"}' }, }, ], }, { role: "tool", content: 'fn main() { println!("hello"); }', tool_call_id: "toolu_1", }, { role: "assistant", content: "The file contains a main function." }, ]; act(() => { capturedWsHandlers?.onUpdate(messages); }); expect(await screen.findByText(/Tool Output/)).toBeInTheDocument(); expect( await screen.findByText("The file contains a main function."), ).toBeInTheDocument(); }); it("renders plain assistant message without tool call badges (AC5)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const messages: Message[] = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there! How can I help?" }, ]; act(() => { capturedWsHandlers?.onUpdate(messages); }); expect( await screen.findByText("Hi there! How can I help?"), ).toBeInTheDocument(); expect(screen.queryByText(/Tool Output/)).toBeNull(); }); it("renders multiple tool calls in a single assistant turn (AC3)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const messages: Message[] = [ { role: "user", content: "Do some work" }, { role: "assistant", content: "I'll do multiple things.", tool_calls: [ { id: "id1", type: "function", function: { name: "Bash", arguments: '{"command":"cargo test"}' }, }, { id: "id2", type: "function", function: { name: "Read", arguments: '{"file_path":"Cargo.toml"}' }, }, ], }, ]; act(() => { capturedWsHandlers?.onUpdate(messages); }); expect(await screen.findByText("Bash(cargo test)")).toBeInTheDocument(); expect(await screen.findByText("Read(Cargo.toml)")).toBeInTheDocument(); }); it("does not fetch Anthropic models when no API key exists", async () => { mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false); mockedApi.getAnthropicModels.mockClear(); render(); await waitFor(() => { expect(mockedApi.getAnthropicApiKeyExists).toHaveBeenCalled(); }); expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled(); }); }); describe("Chat two-column layout", () => { beforeEach(() => { capturedWsHandlers = null; setupMocks(); }); it("renders left and right column containers (AC1, AC2)", async () => { render(); expect(await screen.findByTestId("chat-content-area")).toBeInTheDocument(); expect(await screen.findByTestId("chat-left-column")).toBeInTheDocument(); expect(await screen.findByTestId("chat-right-column")).toBeInTheDocument(); }); it("renders chat input inside the left column (AC2, AC5)", async () => { render(); const leftColumn = await screen.findByTestId("chat-left-column"); const input = screen.getByPlaceholderText("Send a message..."); expect(leftColumn).toContainElement(input); }); it("renders panels inside the right column (AC2)", async () => { render(); const rightColumn = await screen.findByTestId("chat-right-column"); const agentsPanel = await screen.findByText("Agents"); expect(rightColumn).toContainElement(agentsPanel); }); it("uses row flex-direction on wide screens (AC3)", async () => { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200, }); window.dispatchEvent(new Event("resize")); render(); const contentArea = await screen.findByTestId("chat-content-area"); expect(contentArea).toHaveStyle({ flexDirection: "row" }); }); it("uses column flex-direction on narrow screens (AC4)", async () => { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 600, }); window.dispatchEvent(new Event("resize")); render(); const contentArea = await screen.findByTestId("chat-content-area"); expect(contentArea).toHaveStyle({ flexDirection: "column" }); // Restore wide width for subsequent tests Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024, }); }); }); describe("Chat input Shift+Enter behavior", () => { beforeEach(() => { capturedWsHandlers = null; setupMocks(); }); it("renders a textarea element for the chat input (AC3)", async () => { render(); const input = screen.getByPlaceholderText("Send a message..."); expect(input.tagName.toLowerCase()).toBe("textarea"); }); it("sends message on Enter key press without Shift (AC2)", async () => { render(); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Hello" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await waitFor(() => { expect((input as HTMLTextAreaElement).value).toBe(""); }); }); it("does not send message on Shift+Enter (AC1)", async () => { render(); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Hello" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: true }); }); expect((input as HTMLTextAreaElement).value).toBe("Hello"); }); }); describe("Chat reconciliation banner", () => { beforeEach(() => { capturedWsHandlers = null; setupMocks(); }); it("shows banner when a non-done reconciliation event is received", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); act(() => { capturedWsHandlers?.onReconciliationProgress( "42_story_test", "checking", "Checking for committed work in 2_current/", ); }); expect( await screen.findByTestId("reconciliation-banner"), ).toBeInTheDocument(); expect( await screen.findByText("Reconciling startup state..."), ).toBeInTheDocument(); }); it("shows event message in the banner", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); act(() => { capturedWsHandlers?.onReconciliationProgress( "42_story_test", "gates_running", "Running acceptance gates…", ); }); expect( await screen.findByText(/Running acceptance gates/), ).toBeInTheDocument(); }); it("dismisses banner when done event is received", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); act(() => { capturedWsHandlers?.onReconciliationProgress( "42_story_test", "checking", "Checking for committed work", ); }); expect( await screen.findByTestId("reconciliation-banner"), ).toBeInTheDocument(); act(() => { capturedWsHandlers?.onReconciliationProgress( "", "done", "Startup reconciliation complete.", ); }); await waitFor(() => { expect( screen.queryByTestId("reconciliation-banner"), ).not.toBeInTheDocument(); }); }); }); describe("Chat localStorage persistence (Story 145)", () => { const PROJECT_PATH = "/tmp/project"; const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`; beforeEach(() => { capturedWsHandlers = null; localStorage.clear(); setupMocks(); }); afterEach(() => { localStorage.clear(); }); it("AC1: restores persisted messages on mount", async () => { const saved: Message[] = [ { role: "user", content: "Previously saved question" }, { role: "assistant", content: "Previously saved answer" }, ]; localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); render(); expect( await screen.findByText("Previously saved question"), ).toBeInTheDocument(); expect( await screen.findByText("Previously saved answer"), ).toBeInTheDocument(); }); it("AC2: persists messages when WebSocket onUpdate fires", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const history: Message[] = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there!" }, ]; act(() => { capturedWsHandlers?.onUpdate(history); }); const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); expect(stored).toEqual(history); }); it("AC3: clears localStorage when New Session is clicked", async () => { const saved: Message[] = [ { role: "user", content: "Old message" }, { role: "assistant", content: "Old reply" }, ]; localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); // Stub window.confirm to auto-approve the clear dialog const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); render(); // Wait for the persisted message to appear expect(await screen.findByText("Old message")).toBeInTheDocument(); // Click "New Session" button const newSessionBtn = screen.getByText(/New Session/); await act(async () => { fireEvent.click(newSessionBtn); }); // localStorage should be cleared expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); // Messages should be gone from the UI expect(screen.queryByText("Old message")).not.toBeInTheDocument(); confirmSpy.mockRestore(); }); it("AC5: uses project-scoped storage key", async () => { const otherKey = "storykit-chat-history:/other/project"; localStorage.setItem( otherKey, JSON.stringify([{ role: "user", content: "Other project msg" }]), ); render(); // Should NOT show the other project's messages await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); expect(screen.queryByText("Other project msg")).not.toBeInTheDocument(); // Other project's data should still be in storage expect(localStorage.getItem(otherKey)).not.toBeNull(); }); }); describe("Chat activity status indicator (Bug 140)", () => { beforeEach(() => { capturedWsHandlers = null; setupMocks(); }); it("shows activity label when tool activity fires during streaming content", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Simulate sending a message to set loading=true const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Read my file" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Simulate tokens arriving (streamingContent becomes non-empty) act(() => { capturedWsHandlers?.onToken("I'll read that file for you."); }); // Now simulate a tool activity event while streamingContent is non-empty act(() => { capturedWsHandlers?.onActivity("read_file"); }); // The activity indicator should be visible with the tool activity label const indicator = await screen.findByTestId("activity-indicator"); expect(indicator).toBeInTheDocument(); expect(indicator).toHaveTextContent("Reading file..."); }); it("shows Thinking... fallback when loading with no streaming and no activity", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Simulate sending a message to set loading=true const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Hello" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // No tokens, no activity — should show "Thinking..." const indicator = await screen.findByTestId("activity-indicator"); expect(indicator).toBeInTheDocument(); expect(indicator).toHaveTextContent("Thinking..."); }); it("hides Thinking... when streaming content is present but no tool activity", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Simulate sending a message to set loading=true const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Hello" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Tokens arrive — streamingContent is non-empty, no activity act(() => { capturedWsHandlers?.onToken("Here is my response..."); }); // The activity indicator should NOT be visible (just streaming bubble) expect(screen.queryByTestId("activity-indicator")).not.toBeInTheDocument(); }); it("shows activity label for Claude Code tool names (Read, Bash, etc.)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Simulate sending a message to set loading=true const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Read my file" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Simulate tokens arriving act(() => { capturedWsHandlers?.onToken("Let me read that."); }); // Claude Code sends tool name "Read" (not "read_file") act(() => { capturedWsHandlers?.onActivity("Read"); }); const indicator = await screen.findByTestId("activity-indicator"); expect(indicator).toBeInTheDocument(); expect(indicator).toHaveTextContent("Reading file..."); }); it("shows activity label for Claude Code Bash tool", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Run the tests" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); act(() => { capturedWsHandlers?.onToken("Running tests now."); }); act(() => { capturedWsHandlers?.onActivity("Bash"); }); const indicator = await screen.findByTestId("activity-indicator"); expect(indicator).toBeInTheDocument(); expect(indicator).toHaveTextContent("Executing command..."); }); it("shows generic label for unknown tool names", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Do something" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); act(() => { capturedWsHandlers?.onToken("Working on it."); }); act(() => { capturedWsHandlers?.onActivity("SomeCustomTool"); }); const indicator = await screen.findByTestId("activity-indicator"); expect(indicator).toBeInTheDocument(); expect(indicator).toHaveTextContent("Using SomeCustomTool..."); }); }); describe("Chat message queue (Story 155)", () => { beforeEach(() => { capturedWsHandlers = null; setupMocks(); }); it("shows queued message indicator when submitting while loading (AC1, AC2)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Send first message to put the chat in loading state const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "First message" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Now type and submit a second message while loading is true await act(async () => { fireEvent.change(input, { target: { value: "Queued message" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // The queued message indicator should appear const indicator = await screen.findByTestId("queued-message-indicator"); expect(indicator).toBeInTheDocument(); expect(indicator).toHaveTextContent("Queued"); expect(indicator).toHaveTextContent("Queued message"); // Input should be cleared after queuing expect((input as HTMLTextAreaElement).value).toBe(""); }); it("auto-sends queued message when agent response completes (AC4)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); // Send first message await act(async () => { fireEvent.change(input, { target: { value: "First" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Queue a second message while loading await act(async () => { fireEvent.change(input, { target: { value: "Auto-send this" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Verify it's queued expect( await screen.findByTestId("queued-message-indicator"), ).toBeInTheDocument(); // Simulate agent response completing (loading → false) act(() => { capturedWsHandlers?.onUpdate([ { role: "user", content: "First" }, { role: "assistant", content: "Done." }, ]); }); // The queued indicator should disappear (message was sent) await waitFor(() => { expect( screen.queryByTestId("queued-message-indicator"), ).not.toBeInTheDocument(); }); }); it("cancel button discards the queued message (AC3, AC6)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); // Send first message to start loading await act(async () => { fireEvent.change(input, { target: { value: "First" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Queue a second message await act(async () => { fireEvent.change(input, { target: { value: "Discard me" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); const indicator = await screen.findByTestId("queued-message-indicator"); expect(indicator).toBeInTheDocument(); // Click the ✕ cancel button const cancelBtn = screen.getByTitle("Cancel queued message"); await act(async () => { fireEvent.click(cancelBtn); }); // Indicator should be gone expect( screen.queryByTestId("queued-message-indicator"), ).not.toBeInTheDocument(); }); it("edit button puts queued message back into input (AC3)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); // Send first message to start loading await act(async () => { fireEvent.change(input, { target: { value: "First" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Queue a second message await act(async () => { fireEvent.change(input, { target: { value: "Edit me back" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await screen.findByTestId("queued-message-indicator"); // Click the Edit button const editBtn = screen.getByTitle("Edit queued message"); await act(async () => { fireEvent.click(editBtn); }); // Indicator should be gone and message back in input expect( screen.queryByTestId("queued-message-indicator"), ).not.toBeInTheDocument(); expect((input as HTMLTextAreaElement).value).toBe("Edit me back"); }); it("subsequent submissions are appended to the queue (Bug 168)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); // Send first message to start loading await act(async () => { fireEvent.change(input, { target: { value: "First" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Queue first message await act(async () => { fireEvent.change(input, { target: { value: "Queue 1" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await screen.findByTestId("queued-message-indicator"); // Queue second message — should be appended, not overwrite the first await act(async () => { fireEvent.change(input, { target: { value: "Queue 2" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Both messages should be visible const indicators = await screen.findAllByTestId("queued-message-indicator"); expect(indicators).toHaveLength(2); expect(indicators[0]).toHaveTextContent("Queue 1"); expect(indicators[1]).toHaveTextContent("Queue 2"); }); it("queued messages are delivered in order (Bug 168)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); // Send first message to start loading await act(async () => { fireEvent.change(input, { target: { value: "First" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Queue two messages await act(async () => { fireEvent.change(input, { target: { value: "Second" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await act(async () => { fireEvent.change(input, { target: { value: "Third" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Both messages should be visible in order const indicators = await screen.findAllByTestId("queued-message-indicator"); expect(indicators).toHaveLength(2); expect(indicators[0]).toHaveTextContent("Second"); expect(indicators[1]).toHaveTextContent("Third"); // Simulate first response completing — "Second" is sent next act(() => { capturedWsHandlers?.onUpdate([ { role: "user", content: "First" }, { role: "assistant", content: "Response 1." }, ]); }); // "Third" should remain queued; "Second" was consumed await waitFor(() => { const remaining = screen.queryAllByTestId("queued-message-indicator"); expect(remaining).toHaveLength(1); expect(remaining[0]).toHaveTextContent("Third"); }); }); it("does not auto-send queued message when generation is cancelled (AC6)", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); // Send first message to start loading await act(async () => { fireEvent.change(input, { target: { value: "First" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Queue a second message await act(async () => { fireEvent.change(input, { target: { value: "Should not send" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await screen.findByTestId("queued-message-indicator"); // Click the stop button (■) — but input is empty so button is stop // Actually simulate cancel by clicking the stop button (which requires empty input) // We need to use the send button when input is empty (stop mode) // Simulate cancel via the cancelGeneration path: the button when loading && !input // At this point input is empty (was cleared after queuing) const stopButton = screen.getByRole("button", { name: "■" }); await act(async () => { fireEvent.click(stopButton); }); // Queued indicator should be gone (cancelled) await waitFor(() => { expect( screen.queryByTestId("queued-message-indicator"), ).not.toBeInTheDocument(); }); }); }); describe("Remove bubble styling from streaming messages (Story 163)", () => { beforeEach(() => { capturedWsHandlers = null; setupMocks(); }); it("AC1: streaming assistant message uses transparent background, no extra padding, no border-radius", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Send a message to put chat into loading state const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Hello" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Simulate streaming tokens arriving act(() => { capturedWsHandlers?.onToken("Streaming response text"); }); // Find the streaming message container (the inner div wrapping the Markdown) const streamingText = await screen.findByText("Streaming response text"); // The markdown-body wrapper is the parent, and the styled div is its parent const styledDiv = streamingText.closest(".markdown-body") ?.parentElement as HTMLElement; expect(styledDiv).toBeTruthy(); const styleAttr = styledDiv.getAttribute("style") ?? ""; expect(styleAttr).toContain("background: transparent"); expect(styleAttr).toContain("padding: 0px"); expect(styleAttr).toContain("border-radius: 0px"); expect(styleAttr).toContain("max-width: 100%"); }); it("AC1: streaming message wraps Markdown in markdown-body class", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Hello" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); act(() => { capturedWsHandlers?.onToken("Some markdown content"); }); const streamingText = await screen.findByText("Some markdown content"); const markdownBody = streamingText.closest(".markdown-body"); expect(markdownBody).toBeTruthy(); }); it("AC2: no visual change when streaming ends and message transitions to completed", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Send a message to start streaming const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Hello" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Simulate streaming tokens act(() => { capturedWsHandlers?.onToken("Final response"); }); // Capture streaming message style attribute const streamingText = await screen.findByText("Final response"); const streamingStyledDiv = streamingText.closest(".markdown-body") ?.parentElement as HTMLElement; const streamingStyleAttr = streamingStyledDiv.getAttribute("style") ?? ""; // Transition: onUpdate completes the message act(() => { capturedWsHandlers?.onUpdate([ { role: "user", content: "Hello" }, { role: "assistant", content: "Final response" }, ]); }); // Find the completed message — it should have the same styling const completedText = await screen.findByText("Final response"); const completedMarkdownBody = completedText.closest(".markdown-body"); const completedStyledDiv = completedMarkdownBody?.parentElement as HTMLElement; expect(completedStyledDiv).toBeTruthy(); const completedStyleAttr = completedStyledDiv.getAttribute("style") ?? ""; // Both streaming and completed use transparent bg, 0 padding, 0 border-radius expect(completedStyleAttr).toContain("background: transparent"); expect(completedStyleAttr).toContain("padding: 0px"); expect(completedStyleAttr).toContain("border-radius: 0px"); expect(streamingStyleAttr).toContain("background: transparent"); expect(streamingStyleAttr).toContain("padding: 0px"); expect(streamingStyleAttr).toContain("border-radius: 0px"); // Both have the markdown-body class wrapper expect(streamingStyledDiv.querySelector(".markdown-body")).toBeTruthy(); }); it("AC3: completed assistant messages retain transparent background and no border-radius", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); act(() => { capturedWsHandlers?.onUpdate([ { role: "user", content: "Hi" }, { role: "assistant", content: "Hello there!" }, ]); }); const assistantText = await screen.findByText("Hello there!"); const markdownBody = assistantText.closest(".markdown-body"); const styledDiv = markdownBody?.parentElement as HTMLElement; expect(styledDiv).toBeTruthy(); const styleAttr = styledDiv.getAttribute("style") ?? ""; expect(styleAttr).toContain("background: transparent"); expect(styleAttr).toContain("padding: 0px"); expect(styleAttr).toContain("border-radius: 0px"); expect(styleAttr).toContain("max-width: 100%"); }); it("AC3: completed user messages still have their bubble styling", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); act(() => { capturedWsHandlers?.onUpdate([ { role: "user", content: "I am a user message" }, { role: "assistant", content: "I am a response" }, ]); }); // findByText returns the styled div itself for user messages (text is direct child) const userStyledDiv = await screen.findByText("I am a user message"); const styleAttr = userStyledDiv.getAttribute("style") ?? ""; // User messages retain bubble: distinct background, padding, rounded corners expect(styleAttr).toContain("padding: 10px 16px"); expect(styleAttr).toContain("border-radius: 20px"); expect(styleAttr).not.toContain("background: transparent"); }); });