import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { PromptInput } from "./PromptInput"; const { searchFilesMock, listSkillsMock } = vi.hoisted(() => ({ searchFilesMock: vi.fn(), listSkillsMock: vi.fn(), })); vi.mock("../api", () => ({ searchFiles: searchFilesMock, listSkills: listSkillsMock, })); function type(textarea: HTMLTextAreaElement, value: string) { fireEvent.change(textarea, { target: { value } }); } async function settle() { // The file search is debounced (120 ms); flush timers and promises. await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); }); } describe("PromptInput", () => { beforeEach(() => { searchFilesMock.mockReset(); listSkillsMock.mockReset(); searchFilesMock.mockResolvedValue({ root: "/w", files: [ { name: "main.go", path: "/w/src/main.go", relPath: "src/main.go" }, { name: "main_test.go", path: "/w/src/main_test.go", relPath: "src/main_test.go", }, ], }); listSkillsMock.mockResolvedValue({ skills: [ { name: "quality", description: "Measure code quality", path: "/p", source: "project", }, ], }); }); it("sends the trimmed text on Enter and clears the field", () => { const onSend = vi.fn(); render( {}} />, ); const input = screen.getByLabelText("prompt") as HTMLTextAreaElement; type(input, " hello "); fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).toHaveBeenCalledWith("hello", []); expect(input.value).toBe(""); }); it("opens the file popup on @, navigates with arrows and inserts the pick as an attachment", async () => { const onSend = vi.fn(); render( {}} />, ); const input = screen.getByLabelText("prompt") as HTMLTextAreaElement; type(input, "look at @ma"); await settle(); expect(searchFilesMock).toHaveBeenCalledWith("ma", 30); const options = screen.getAllByRole("option"); expect(options.map((o) => o.textContent)).toEqual([ "src/main.go", "src/main_test.go", ]); expect(options[0].getAttribute("aria-selected")).toBe("true"); fireEvent.keyDown(input, { key: "ArrowDown" }); expect(screen.getAllByRole("option")[1].getAttribute("aria-selected")).toBe( "true", ); fireEvent.keyDown(input, { key: "ArrowUp" }); expect(screen.getAllByRole("option")[0].getAttribute("aria-selected")).toBe( "true", ); // Enter picks instead of sending while the popup is open. fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).not.toHaveBeenCalled(); expect(input.value).toBe("look at @src/main.go "); expect(screen.queryByRole("listbox")).toBeNull(); type(input, "look at @src/main.go please"); fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).toHaveBeenCalledWith("look at @src/main.go please", [ { path: "/w/src/main.go", name: "src/main.go" }, ]); }); it("drops an attachment whose mention was deleted before sending", async () => { const onSend = vi.fn(); render( {}} />, ); const input = screen.getByLabelText("prompt") as HTMLTextAreaElement; type(input, "@ma"); await settle(); fireEvent.keyDown(input, { key: "Tab" }); expect(input.value).toBe("@src/main.go "); type(input, "no mention any more"); fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).toHaveBeenCalledWith("no mention any more", []); }); it("closes the popup with Escape and does not reopen for the same trigger", async () => { render( {}} onCancel={() => {}} />, ); const input = screen.getByLabelText("prompt") as HTMLTextAreaElement; type(input, "@ma"); await settle(); expect(screen.getByRole("listbox")).toBeDefined(); fireEvent.keyDown(input, { key: "Escape" }); expect(screen.queryByRole("listbox")).toBeNull(); type(input, "@mai"); await settle(); expect(screen.queryByRole("listbox")).toBeNull(); }); it("does not open the popup for an @ inside a word", async () => { render( {}} onCancel={() => {}} />, ); type(screen.getByLabelText("prompt") as HTMLTextAreaElement, "me@example"); await settle(); expect(searchFilesMock).not.toHaveBeenCalled(); expect(screen.queryByRole("listbox")).toBeNull(); }); it("opens the skill popup on a leading slash, merging skills and agent commands", async () => { const onSend = vi.fn(); render( {}} />, ); const input = screen.getByLabelText("prompt") as HTMLTextAreaElement; type(input, "/"); await settle(); const options = screen.getAllByRole("option"); expect( options.map((o) => o.querySelector(".completion-label")?.textContent), ).toEqual(["compact", "quality"]); expect(options[0].querySelector(".completion-source")?.textContent).toBe( "command", ); expect(options[1].querySelector(".completion-source")?.textContent).toBe( "skill", ); expect(screen.getByText("Measure code quality")).toBeDefined(); type(input, "/qua"); expect(screen.getAllByRole("option")).toHaveLength(1); fireEvent.mouseDown(screen.getByRole("option")); expect(input.value).toBe("/quality "); type(input, "/quality src"); fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).toHaveBeenCalledWith("/quality src", []); }); it("shows Stop instead of Send while a turn runs", () => { const onCancel = vi.fn(); render( {}} onCancel={onCancel} />, ); fireEvent.click(screen.getByRole("button", { name: "Stop" })); expect(onCancel).toHaveBeenCalled(); }); });