import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { act, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "test-utils"; import { addUserMessage } from "#/state/chat-slice"; import { SUGGESTIONS } from "#/utils/suggestions"; import * as ChatSlice from "#/state/chat-slice"; import { WsClientProviderStatus } from "#/context/ws-client-provider"; import { ChatInterface } from "#/components/features/chat/chat-interface"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const renderChatInterface = (messages: Message[]) => renderWithProviders(); describe("Empty state", () => { const { send: sendMock } = vi.hoisted(() => ({ send: vi.fn(), })); const { useWsClient: useWsClientMock } = vi.hoisted(() => ({ useWsClient: vi.fn(() => ({ send: sendMock, status: WsClientProviderStatus.CONNECTED, isLoadingMessages: false, })), })); beforeAll(() => { vi.mock("react-router", async (importActual) => ({ ...(await importActual()), useRouteLoaderData: vi.fn(() => ({})), })); vi.mock("#/context/socket", async (importActual) => ({ ...(await importActual()), useWsClient: useWsClientMock, })); }); afterEach(() => { vi.clearAllMocks(); }); it("should render suggestions if empty", () => { const { store } = renderWithProviders(, { preloadedState: { chat: { messages: [] }, }, }); expect(screen.getByTestId("suggestions")).toBeInTheDocument(); act(() => { store.dispatch( addUserMessage({ content: "Hello", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }), ); }); expect(screen.queryByTestId("suggestions")).not.toBeInTheDocument(); }); it("should render the default suggestions", () => { renderWithProviders(, { preloadedState: { chat: { messages: [] }, }, }); const suggestions = screen.getByTestId("suggestions"); const repoSuggestions = Object.keys(SUGGESTIONS.repo); // check that there are at most 4 suggestions displayed const displayedSuggestions = within(suggestions).getAllByRole("button"); expect(displayedSuggestions.length).toBeLessThanOrEqual(4); // Check that each displayed suggestion is one of the repo suggestions displayedSuggestions.forEach((suggestion) => { expect(repoSuggestions).toContain(suggestion.textContent); }); }); it.fails( "should load the a user message to the input when selecting", async () => { // this is to test that the message is in the UI before the socket is called useWsClientMock.mockImplementation(() => ({ send: sendMock, status: WsClientProviderStatus.CONNECTED, isLoadingMessages: false, })); const addUserMessageSpy = vi.spyOn(ChatSlice, "addUserMessage"); const user = userEvent.setup(); const { store } = renderWithProviders(, { preloadedState: { chat: { messages: [] }, }, }); const suggestions = screen.getByTestId("suggestions"); const displayedSuggestions = within(suggestions).getAllByRole("button"); const input = screen.getByTestId("chat-input"); await user.click(displayedSuggestions[0]); // user message loaded to input expect(addUserMessageSpy).not.toHaveBeenCalled(); expect(screen.queryByTestId("suggestions")).toBeInTheDocument(); expect(store.getState().chat.messages).toHaveLength(0); expect(input).toHaveValue(displayedSuggestions[0].textContent); }, ); it.fails( "should send the message to the socket only if the runtime is active", async () => { useWsClientMock.mockImplementation(() => ({ send: sendMock, status: WsClientProviderStatus.CONNECTED, isLoadingMessages: false, })); const user = userEvent.setup(); const { rerender } = renderWithProviders(, { preloadedState: { chat: { messages: [] }, }, }); const suggestions = screen.getByTestId("suggestions"); const displayedSuggestions = within(suggestions).getAllByRole("button"); await user.click(displayedSuggestions[0]); expect(sendMock).not.toHaveBeenCalled(); useWsClientMock.mockImplementation(() => ({ send: sendMock, status: WsClientProviderStatus.CONNECTED, isLoadingMessages: false, })); rerender(); await waitFor(() => expect(sendMock).toHaveBeenCalledWith(expect.any(String)), ); }, ); }); describe.skip("ChatInterface", () => { beforeAll(() => { // mock useScrollToBottom hook vi.mock("#/hooks/useScrollToBottom", () => ({ useScrollToBottom: vi.fn(() => ({ scrollDomToBottom: vi.fn(), onChatBodyScroll: vi.fn(), hitBottom: vi.fn(), })), })); }); afterEach(() => { vi.clearAllMocks(); }); it("should render messages", () => { const messages: Message[] = [ { sender: "user", content: "Hello", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }, { sender: "assistant", content: "Hi", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }, ]; renderChatInterface(messages); expect(screen.getAllByTestId(/-message/)).toHaveLength(2); }); it("should render a chat input", () => { const messages: Message[] = []; renderChatInterface(messages); expect(screen.getByTestId("chat-input")).toBeInTheDocument(); }); it("should call socket send when submitting a message", async () => { const user = userEvent.setup(); const messages: Message[] = []; renderChatInterface(messages); const input = screen.getByTestId("chat-input"); await user.type(input, "Hello"); await user.keyboard("{Enter}"); // spy on send and expect to have been called }); it("should render an image carousel with a message", () => { let messages: Message[] = [ { sender: "assistant", content: "Here are some images", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }, ]; const { rerender } = renderChatInterface(messages); expect(screen.queryByTestId("image-carousel")).not.toBeInTheDocument(); messages = [ { sender: "assistant", content: "Here are some images", imageUrls: ["image1", "image2"], timestamp: new Date().toISOString(), pending: true, }, ]; rerender(); const imageCarousel = screen.getByTestId("image-carousel"); expect(imageCarousel).toBeInTheDocument(); expect(within(imageCarousel).getAllByTestId("image-preview")).toHaveLength( 2, ); }); it("should render a 'continue' action when there are more than 2 messages and awaiting user input", () => { const messages: Message[] = [ { sender: "assistant", content: "Hello", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }, { sender: "user", content: "Hi", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }, ]; const { rerender } = renderChatInterface(messages); expect( screen.queryByTestId("continue-action-button"), ).not.toBeInTheDocument(); messages.push({ sender: "assistant", content: "How can I help you?", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }); rerender(); expect(screen.getByTestId("continue-action-button")).toBeInTheDocument(); }); it("should render inline errors", () => { const messages: Message[] = [ { sender: "assistant", content: "Hello", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }, { type: "error", content: "Something went wrong", sender: "assistant", timestamp: new Date().toISOString(), }, ]; renderChatInterface(messages); const error = screen.getByTestId("error-message"); expect(within(error).getByText("Something went wrong")).toBeInTheDocument(); }); it("should render both GitHub buttons initially when ghToken is available", () => { vi.mock("react-router", async (importActual) => ({ ...(await importActual()), useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })), })); const messages: Message[] = [ { sender: "assistant", content: "Hello", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }, ]; renderChatInterface(messages); const pushButton = screen.getByRole("button", { name: "Push to Branch" }); const prButton = screen.getByRole("button", { name: "Push & Create PR" }); expect(pushButton).toBeInTheDocument(); expect(prButton).toBeInTheDocument(); expect(pushButton).toHaveTextContent("Push to Branch"); expect(prButton).toHaveTextContent("Push & Create PR"); }); it("should render only 'Push changes to PR' button after PR is created", async () => { vi.mock("react-router", async (importActual) => ({ ...(await importActual()), useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })), })); const messages: Message[] = [ { sender: "assistant", content: "Hello", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }, ]; const { rerender } = renderChatInterface(messages); const user = userEvent.setup(); // Click the "Push & Create PR" button const prButton = screen.getByRole("button", { name: "Push & Create PR" }); await user.click(prButton); // Re-render to trigger state update rerender(); // Verify only one button is shown const pushToPrButton = screen.getByRole("button", { name: "Push changes to PR", }); expect(pushToPrButton).toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Push to Branch" }), ).not.toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Push & Create PR" }), ).not.toBeInTheDocument(); }); it("should render feedback actions if there are more than 3 messages", () => { const messages: Message[] = [ { sender: "assistant", content: "Hello", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }, { sender: "user", content: "Hi", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }, { sender: "assistant", content: "How can I help you?", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }, ]; const { rerender } = renderChatInterface(messages); expect(screen.queryByTestId("feedback-actions")).not.toBeInTheDocument(); messages.push({ sender: "user", content: "I need help", imageUrls: [], timestamp: new Date().toISOString(), pending: true, }); rerender(); expect(screen.getByTestId("feedback-actions")).toBeInTheDocument(); }); });