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(). 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; // Captures the last sendChat call's arguments for assertion. let lastSendChatArgs: { messages: Message[]; config: unknown } | 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(), readFile: vi.fn(), listProjectFiles: vi.fn(), botCommand: vi.fn(), }; class ChatWebSocket { connect(handlers: WsHandlers) { capturedWsHandlers = handlers; } close() {} sendChat(messages: Message[], config: unknown) { lastSendChatArgs = { messages, config }; } 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), readFile: vi.mocked(api.readFile), listProjectFiles: vi.mocked(api.listProjectFiles), botCommand: vi.mocked(api.botCommand), }; function setupMocks() { mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]); mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true); mockedApi.getAnthropicModels.mockResolvedValue([]); mockedApi.getModelPreference.mockResolvedValue(null); mockedApi.setModelPreference.mockResolvedValue(true); mockedApi.readFile.mockResolvedValue(""); mockedApi.listProjectFiles.mockResolvedValue([]); mockedApi.cancelChat.mockResolvedValue(true); mockedApi.setAnthropicApiKey.mockResolvedValue(true); mockedApi.botCommand.mockResolvedValue({ response: "Bot response" }); } 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 await act(async () => { 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 }); }); await act(async () => { 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 await act(async () => { 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 await act(async () => { 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()); await act(async () => { 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()); await act(async () => { capturedWsHandlers?.onUpdate([ { role: "user", content: "I am a user message" }, { role: "assistant", content: "I am a response" }, ]); }); // findByText finds the text element; traverse up to the styled bubble div const userText = await screen.findByText("I am a user message"); // User messages are rendered via markdown, so text is inside a

inside .user-markdown-body // Walk up to find the styled bubble container const bubbleDiv = userText.closest("[style*='padding: 10px 16px']"); expect(bubbleDiv).toBeTruthy(); const styleAttr = bubbleDiv?.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"); }); }); describe("Slash command handling (Story 374)", () => { beforeEach(() => { capturedWsHandlers = null; lastSendChatArgs = null; setupMocks(); }); afterEach(() => { vi.clearAllMocks(); }); it("AC: /status calls botCommand and displays response", async () => { mockedApi.botCommand.mockResolvedValue({ response: "Pipeline: 3 active" }); render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "/status" } }); }); // First Enter selects the command from the picker; second Enter submits it await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await waitFor(() => { expect(mockedApi.botCommand).toHaveBeenCalledWith( "status", "", ); }); expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument(); // Should NOT go to LLM expect(lastSendChatArgs).toBeNull(); }); it("AC: /status passes args to botCommand", async () => { mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" }); render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "/status 42" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await waitFor(() => { expect(mockedApi.botCommand).toHaveBeenCalledWith( "status", "42", ); }); }); it("AC: /start calls botCommand", async () => { mockedApi.botCommand.mockResolvedValue({ response: "Started agent" }); render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "/start 42 opus" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await waitFor(() => { expect(mockedApi.botCommand).toHaveBeenCalledWith( "start", "42 opus", ); }); expect(await screen.findByText("Started agent")).toBeInTheDocument(); }); it("AC: /git calls botCommand", async () => { mockedApi.botCommand.mockResolvedValue({ response: "On branch main" }); render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "/git" } }); }); // First Enter selects the command from the picker; second Enter submits it await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await waitFor(() => { expect(mockedApi.botCommand).toHaveBeenCalledWith("git", ""); }); }); it("AC: /cost calls botCommand", async () => { mockedApi.botCommand.mockResolvedValue({ response: "$1.23 today" }); render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "/cost" } }); }); // First Enter selects the command from the picker; second Enter submits it await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await waitFor(() => { expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", ""); }); }); it("AC: /reset clears messages and session without LLM", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // First add a message so there is history to clear await act(async () => { capturedWsHandlers?.onUpdate([ { role: "user", content: "hello" }, { role: "assistant", content: "world" }, ]); }); expect(await screen.findByText("world")).toBeInTheDocument(); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "/reset" } }); }); // First Enter selects the command from the picker; second Enter submits it await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // LLM must NOT be invoked expect(lastSendChatArgs).toBeNull(); // botCommand must NOT be invoked (reset is frontend-only) expect(mockedApi.botCommand).not.toHaveBeenCalled(); // Confirmation message should appear expect(await screen.findByText(/Session reset/)).toBeInTheDocument(); }); it("AC: unrecognised slash command shows error message", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "/foobar" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); expect(await screen.findByText(/Unknown command/)).toBeInTheDocument(); // Should NOT go to LLM expect(lastSendChatArgs).toBeNull(); // Should NOT call botCommand expect(mockedApi.botCommand).not.toHaveBeenCalled(); }); it("AC: /help calls botCommand and displays response", async () => { mockedApi.botCommand.mockResolvedValue({ response: "Available commands: status, help, ...", }); render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "/help" } }); }); // First Enter selects the command from the picker; second Enter submits it await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await waitFor(() => { expect(mockedApi.botCommand).toHaveBeenCalledWith("help", ""); }); expect(lastSendChatArgs).toBeNull(); }); it("AC: botCommand API error shows error message in chat", async () => { mockedApi.botCommand.mockRejectedValue(new Error("Server error")); render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "/git" } }); }); // First Enter selects the command from the picker; second Enter submits it await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); expect( await screen.findByText(/Error running command/), ).toBeInTheDocument(); }); }); describe("Bug 450: WebSocket error messages displayed in chat", () => { beforeEach(() => { capturedWsHandlers = null; setupMocks(); }); it("AC1: WebSocket error message is shown in chat as an assistant message", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); await act(async () => { capturedWsHandlers?.onError("Something went wrong on the server."); }); expect( await screen.findByText("Something went wrong on the server."), ).toBeInTheDocument(); }); it("AC2: OAuth login URL in WebSocket error is rendered as a clickable link", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); await act(async () => { capturedWsHandlers?.onError( "OAuth login required. Please visit: https://example.com/oauth/login", ); }); const link = await screen.findByRole("link", { name: /https:\/\/example\.com\/oauth\/login/, }); expect(link).toBeInTheDocument(); expect(link).toHaveAttribute("href", "https://example.com/oauth/login"); }); });