/** * @file ChatInput.test.tsx * @description Tests for ChatInput: model selector, temperature/params, localStorage persistence, * and command autocomplete. */ import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { ChatInput, AVAILABLE_MODELS, DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS, } from "./ChatInput"; // Mock fetch for version.json beforeEach(() => { global.fetch = vi.fn().mockRejectedValue(new Error("Not found")); }); afterEach(() => { vi.restoreAllMocks(); localStorage.clear(); }); /** Get the first non-default model from the list */ function getNonDefaultModel(): (typeof AVAILABLE_MODELS)[number] { const model = AVAILABLE_MODELS.find((m) => m.id !== DEFAULT_MODEL); if (!model) throw new Error("No non-default model found"); return model; } describe("ChatInput — model selector", () => { it("should render the model selector chip showing the default model", () => { render(); const defaultLabel = AVAILABLE_MODELS.find((m) => m.id === DEFAULT_MODEL)?.label ?? DEFAULT_MODEL; expect(screen.getByText(defaultLabel)).toBeDefined(); }); it("should open the model dropdown when the chip is clicked", () => { render(); const chip = screen.getByLabelText(/model:/i); fireEvent.click(chip); // The dropdown (listbox role) should be visible const listbox = screen.getByRole("listbox", { name: /available models/i }); expect(listbox).toBeDefined(); // All model options should appear in the dropdown const options = within(listbox).getAllByRole("option"); expect(options.length).toBe(AVAILABLE_MODELS.length); }); it("should call onModelChange when a model is selected", async () => { const onModelChange = vi.fn(); render(); const chip = screen.getByLabelText(/model:/i); fireEvent.click(chip); const targetModel = getNonDefaultModel(); const listbox = screen.getByRole("listbox", { name: /available models/i }); const targetOption = within(listbox).getByText(targetModel.label); fireEvent.click(targetOption); await waitFor(() => { const calls = onModelChange.mock.calls.map((c: unknown[]) => c[0]); expect(calls).toContain(targetModel.id); }); }); it("should persist the selected model in localStorage", async () => { render(); const chip = screen.getByLabelText(/model:/i); fireEvent.click(chip); const targetModel = getNonDefaultModel(); const listbox = screen.getByRole("listbox", { name: /available models/i }); const targetOption = within(listbox).getByText(targetModel.label); fireEvent.click(targetOption); await waitFor(() => { expect(localStorage.getItem("chat:selectedModel")).toBe(targetModel.id); }); }); it("should restore the model from localStorage on mount", async () => { const targetModel = getNonDefaultModel(); localStorage.setItem("chat:selectedModel", targetModel.id); render(); await waitFor(() => { expect(screen.getByText(targetModel.label)).toBeDefined(); }); }); it("should close the dropdown after selecting a model", async () => { render(); const chip = screen.getByLabelText(/model:/i); fireEvent.click(chip); const targetModel = getNonDefaultModel(); const listbox = screen.getByRole("listbox", { name: /available models/i }); const targetOption = within(listbox).getByText(targetModel.label); fireEvent.click(targetOption); // After selection, dropdown should close await waitFor(() => { expect(screen.queryByRole("listbox")).toBeNull(); }); }); it("should have aria-expanded on the model chip button", () => { render(); const chip = screen.getByLabelText(/model:/i); expect(chip.getAttribute("aria-expanded")).toBe("false"); fireEvent.click(chip); expect(chip.getAttribute("aria-expanded")).toBe("true"); }); }); describe("ChatInput — temperature and max tokens", () => { it("should render the settings/params button", () => { render(); const settingsBtn = screen.getByLabelText(/chat parameters/i); expect(settingsBtn).toBeDefined(); }); it("should open the params popover when settings button is clicked", () => { render(); const settingsBtn = screen.getByLabelText(/chat parameters/i); fireEvent.click(settingsBtn); expect(screen.getByLabelText(/temperature/i)).toBeDefined(); expect(screen.getByLabelText(/maximum tokens/i)).toBeDefined(); }); it("should show the default temperature value", () => { render(); fireEvent.click(screen.getByLabelText(/chat parameters/i)); const slider = screen.getByLabelText(/temperature/i); expect(parseFloat((slider as HTMLInputElement).value)).toBeCloseTo(DEFAULT_TEMPERATURE); }); it("should call onTemperatureChange when the slider is moved", async () => { const onTemperatureChange = vi.fn(); render(); fireEvent.click(screen.getByLabelText(/chat parameters/i)); const slider = screen.getByLabelText(/temperature/i); fireEvent.change(slider, { target: { value: "1.2" } }); await waitFor(() => { const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]); expect(calls).toContain(1.2); }); }); it("should persist temperature in localStorage", async () => { render(); fireEvent.click(screen.getByLabelText(/chat parameters/i)); const slider = screen.getByLabelText(/temperature/i); fireEvent.change(slider, { target: { value: "0.5" } }); await waitFor(() => { expect(localStorage.getItem("chat:temperature")).toBe("0.5"); }); }); it("should restore temperature from localStorage on mount", async () => { localStorage.setItem("chat:temperature", "1.5"); const onTemperatureChange = vi.fn(); render(); await waitFor(() => { const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]); expect(calls).toContain(1.5); }); }); it("should show the default max tokens value", () => { render(); fireEvent.click(screen.getByLabelText(/chat parameters/i)); const input = screen.getByLabelText(/maximum tokens/i); expect(parseInt((input as HTMLInputElement).value, 10)).toBe(DEFAULT_MAX_TOKENS); }); it("should call onMaxTokensChange when the max tokens input changes", async () => { const onMaxTokensChange = vi.fn(); render(); fireEvent.click(screen.getByLabelText(/chat parameters/i)); const input = screen.getByLabelText(/maximum tokens/i); fireEvent.change(input, { target: { value: "8192" } }); await waitFor(() => { const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]); expect(calls).toContain(8192); }); }); it("should persist max tokens in localStorage", async () => { render(); fireEvent.click(screen.getByLabelText(/chat parameters/i)); const input = screen.getByLabelText(/maximum tokens/i); fireEvent.change(input, { target: { value: "2000" } }); await waitFor(() => { expect(localStorage.getItem("chat:maxTokens")).toBe("2000"); }); }); it("should restore max tokens from localStorage on mount", async () => { localStorage.setItem("chat:maxTokens", "8000"); const onMaxTokensChange = vi.fn(); render(); await waitFor(() => { const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]); expect(calls).toContain(8000); }); }); }); describe("ChatInput — externalValue (suggestion fill)", () => { it("should update the textarea when externalValue is provided", async () => { const { rerender } = render(); const textarea = screen.getByLabelText(/message input/i); expect((textarea as HTMLTextAreaElement).value).toBe(""); rerender(); await waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe("Hello suggestion"); }); }); }); describe("ChatInput — send behavior", () => { it("should call onSend with the message when the send button is clicked", async () => { const onSend = vi.fn(); render(); const textarea = screen.getByLabelText(/message input/i); fireEvent.change(textarea, { target: { value: "Hello world" } }); const sendButton = screen.getByLabelText(/send message/i); fireEvent.click(sendButton); await waitFor(() => { expect(onSend).toHaveBeenCalledWith("Hello world"); }); }); it("should clear the textarea after sending", async () => { const onSend = vi.fn(); render(); const textarea = screen.getByLabelText(/message input/i); fireEvent.change(textarea, { target: { value: "Hello world" } }); fireEvent.click(screen.getByLabelText(/send message/i)); await waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe(""); }); }); it("should show the stop button when streaming", () => { render(); expect(screen.getByLabelText(/stop generating/i)).toBeDefined(); }); it("should call onStopStreaming when stop button is clicked", () => { const onStop = vi.fn(); render(); fireEvent.click(screen.getByLabelText(/stop generating/i)); expect(onStop).toHaveBeenCalledOnce(); }); }); describe("ChatInput — command autocomplete", () => { it("shows no autocomplete for regular text", () => { render(); const textarea = screen.getByLabelText(/message input/i); act(() => { fireEvent.change(textarea, { target: { value: "hello world" } }); }); expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument(); }); it("shows autocomplete dropdown when user types /", async () => { render(); const textarea = screen.getByLabelText(/message input/i); act(() => { fireEvent.change(textarea, { target: { value: "/" } }); }); await waitFor(() => { expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); }); }); it("shows all commands when only / is typed", async () => { render(); const textarea = screen.getByLabelText(/message input/i); act(() => { fireEvent.change(textarea, { target: { value: "/" } }); }); await waitFor(() => { const dropdown = screen.getByTestId("command-autocomplete"); expect(dropdown).toHaveTextContent("/status"); expect(dropdown).toHaveTextContent("/agents"); expect(dropdown).toHaveTextContent("/jobs"); expect(dropdown).toHaveTextContent("/pause"); expect(dropdown).toHaveTextContent("/resume"); expect(dropdown).toHaveTextContent("/help"); }); }); it("filters commands by typed prefix", async () => { render(); const textarea = screen.getByLabelText(/message input/i); act(() => { fireEvent.change(textarea, { target: { value: "/ag" } }); }); await waitFor(() => { const dropdown = screen.getByTestId("command-autocomplete"); expect(dropdown).toHaveTextContent("/agents"); expect(dropdown).not.toHaveTextContent("/status"); expect(dropdown).not.toHaveTextContent("/pause"); }); }); it("dismisses autocomplete on Escape key", async () => { render(); const textarea = screen.getByLabelText(/message input/i); act(() => { fireEvent.change(textarea, { target: { value: "/" } }); }); await waitFor(() => { expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); }); act(() => { fireEvent.keyDown(textarea, { key: "Escape" }); }); await waitFor(() => { expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument(); }); }); it("accepts first command on Tab key", async () => { render(); const textarea = screen.getByLabelText(/message input/i); act(() => { fireEvent.change(textarea, { target: { value: "/stat" } }); }); await waitFor(() => { expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); }); act(() => { fireEvent.keyDown(textarea, { key: "Tab" }); }); await waitFor(() => { expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument(); }); expect((textarea as HTMLTextAreaElement).value).toBe("/status "); }); it("navigates with ArrowDown key", async () => { render(); const textarea = screen.getByLabelText(/message input/i); act(() => { fireEvent.change(textarea, { target: { value: "/" } }); }); await waitFor(() => { expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); }); act(() => { fireEvent.keyDown(textarea, { key: "ArrowDown" }); }); await waitFor(() => { const options = screen.getAllByRole("option"); // Second item should be selected after ArrowDown expect(options[1]).toHaveAttribute("aria-selected", "true"); }); }); it("fills command when clicking a suggestion", async () => { render(); const textarea = screen.getByLabelText(/message input/i); act(() => { fireEvent.change(textarea, { target: { value: "/" } }); }); await waitFor(() => { expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); }); // Click on /agents option const options = screen.getAllByRole("option"); const agentsOption = options.find((o) => o.textContent.includes("/agents")); if (!agentsOption) throw new Error("Could not find /agents option"); act(() => { fireEvent.click(agentsOption); }); await waitFor(() => { expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument(); }); expect((textarea as HTMLTextAreaElement).value).toBe("/agents "); }); it("shows command descriptions", async () => { render(); const textarea = screen.getByLabelText(/message input/i); act(() => { fireEvent.change(textarea, { target: { value: "/" } }); }); await waitFor(() => { const dropdown = screen.getByTestId("command-autocomplete"); expect(dropdown).toHaveTextContent("Show orchestrator health"); expect(dropdown).toHaveTextContent("Pause the job queue"); }); }); it("hides autocomplete when input no longer starts with /", async () => { render(); const textarea = screen.getByLabelText(/message input/i); act(() => { fireEvent.change(textarea, { target: { value: "/" } }); }); await waitFor(() => { expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); }); act(() => { fireEvent.change(textarea, { target: { value: "" } }); }); await waitFor(() => { expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument(); }); }); });