import { act, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import { 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; 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() {} 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("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()); await act(async () => { 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()); await act(async () => { 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()); await act(async () => { capturedWsHandlers?.onReconciliationProgress( "42_story_test", "checking", "Checking for committed work", ); }); expect( await screen.findByTestId("reconciliation-banner"), ).toBeInTheDocument(); await act(async () => { capturedWsHandlers?.onReconciliationProgress( "", "done", "Startup reconciliation complete.", ); }); await waitFor(() => { expect( screen.queryByTestId("reconciliation-banner"), ).not.toBeInTheDocument(); }); }); });