forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | import { act, fireEvent, render, screen } from "@testing-library/react"; |
| 2 | import { beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | import { PromptInput } from "./PromptInput"; | |
| 4 | ||
| 5 | const { searchFilesMock, listSkillsMock } = vi.hoisted(() => ({ | |
| 6 | searchFilesMock: vi.fn(), | |
| 7 | listSkillsMock: vi.fn(), | |
| 8 | })); | |
| 9 | ||
| 10 | vi.mock("../api", () => ({ | |
| 11 | searchFiles: searchFilesMock, | |
| 12 | listSkills: listSkillsMock, | |
| 13 | })); | |
| 14 | ||
| 15 | function type(textarea: HTMLTextAreaElement, value: string) { | |
| 16 | fireEvent.change(textarea, { target: { value } }); | |
| 17 | } | |
| 18 | ||
| 19 | async function settle() { | |
| 20 | // The file search is debounced (120 ms); flush timers and promises. | |
| 21 | await act(async () => { | |
| 22 | await new Promise((resolve) => setTimeout(resolve, 150)); | |
| 23 | }); | |
| 24 | } | |
| 25 | ||
| 26 | describe("PromptInput", () => { | |
| 27 | beforeEach(() => { | |
| 28 | searchFilesMock.mockReset(); | |
| 29 | listSkillsMock.mockReset(); | |
| 30 | searchFilesMock.mockResolvedValue({ | |
| 31 | root: "/w", | |
| 32 | files: [ | |
| 33 | { name: "main.go", path: "/w/src/main.go", relPath: "src/main.go" }, | |
| 34 | { | |
| 35 | name: "main_test.go", | |
| 36 | path: "/w/src/main_test.go", | |
| 37 | relPath: "src/main_test.go", | |
| 38 | }, | |
| 39 | ], | |
| 40 | }); | |
| 41 | listSkillsMock.mockResolvedValue({ | |
| 42 | skills: [ | |
| 43 | { | |
| 44 | name: "quality", | |
| 45 | description: "Measure code quality", | |
| 46 | path: "/p", | |
| 47 | source: "project", | |
| 48 | }, | |
| 49 | ], | |
| 50 | }); | |
| 51 | }); | |
| 52 | ||
| 53 | it("sends the trimmed text on Enter and clears the field", () => { | |
| 54 | const onSend = vi.fn(); | |
| 55 | render( | |
| 56 | <PromptInput | |
| 57 | turnActive={false} | |
| 58 | commands={[]} | |
| 59 | onSend={onSend} | |
| 60 | onCancel={() => {}} | |
| 61 | />, | |
| 62 | ); | |
| 63 | const input = screen.getByLabelText("prompt") as HTMLTextAreaElement; | |
| 64 | ||
| 65 | type(input, " hello "); | |
| 66 | fireEvent.keyDown(input, { key: "Enter" }); | |
| 67 | expect(onSend).toHaveBeenCalledWith("hello", []); | |
| 68 | expect(input.value).toBe(""); | |
| 69 | }); | |
| 70 | ||
| 71 | it("opens the file popup on @, navigates with arrows and inserts the pick as an attachment", async () => { | |
| 72 | const onSend = vi.fn(); | |
| 73 | render( | |
| 74 | <PromptInput | |
| 75 | turnActive={false} | |
| 76 | commands={[]} | |
| 77 | onSend={onSend} | |
| 78 | onCancel={() => {}} | |
| 79 | />, | |
| 80 | ); | |
| 81 | const input = screen.getByLabelText("prompt") as HTMLTextAreaElement; | |
| 82 | ||
| 83 | type(input, "look at @ma"); | |
| 84 | await settle(); | |
| 85 | expect(searchFilesMock).toHaveBeenCalledWith("ma", 30); | |
| 86 | const options = screen.getAllByRole("option"); | |
| 87 | expect(options.map((o) => o.textContent)).toEqual([ | |
| 88 | "src/main.go", | |
| 89 | "src/main_test.go", | |
| 90 | ]); | |
| 91 | expect(options[0].getAttribute("aria-selected")).toBe("true"); | |
| 92 | ||
| 93 | fireEvent.keyDown(input, { key: "ArrowDown" }); | |
| 94 | expect(screen.getAllByRole("option")[1].getAttribute("aria-selected")).toBe( | |
| 95 | "true", | |
| 96 | ); | |
| 97 | fireEvent.keyDown(input, { key: "ArrowUp" }); | |
| 98 | expect(screen.getAllByRole("option")[0].getAttribute("aria-selected")).toBe( | |
| 99 | "true", | |
| 100 | ); | |
| 101 | ||
| 102 | // Enter picks instead of sending while the popup is open. | |
| 103 | fireEvent.keyDown(input, { key: "Enter" }); | |
| 104 | expect(onSend).not.toHaveBeenCalled(); | |
| 105 | expect(input.value).toBe("look at @src/main.go "); | |
| 106 | expect(screen.queryByRole("listbox")).toBeNull(); | |
| 107 | ||
| 108 | type(input, "look at @src/main.go please"); | |
| 109 | fireEvent.keyDown(input, { key: "Enter" }); | |
| 110 | expect(onSend).toHaveBeenCalledWith("look at @src/main.go please", [ | |
| 111 | { path: "/w/src/main.go", name: "src/main.go" }, | |
| 112 | ]); | |
| 113 | }); | |
| 114 | ||
| 115 | it("drops an attachment whose mention was deleted before sending", async () => { | |
| 116 | const onSend = vi.fn(); | |
| 117 | render( | |
| 118 | <PromptInput | |
| 119 | turnActive={false} | |
| 120 | commands={[]} | |
| 121 | onSend={onSend} | |
| 122 | onCancel={() => {}} | |
| 123 | />, | |
| 124 | ); | |
| 125 | const input = screen.getByLabelText("prompt") as HTMLTextAreaElement; | |
| 126 | ||
| 127 | type(input, "@ma"); | |
| 128 | await settle(); | |
| 129 | fireEvent.keyDown(input, { key: "Tab" }); | |
| 130 | expect(input.value).toBe("@src/main.go "); | |
| 131 | ||
| 132 | type(input, "no mention any more"); | |
| 133 | fireEvent.keyDown(input, { key: "Enter" }); | |
| 134 | expect(onSend).toHaveBeenCalledWith("no mention any more", []); | |
| 135 | }); | |
| 136 | ||
| 137 | it("closes the popup with Escape and does not reopen for the same trigger", async () => { | |
| 138 | render( | |
| 139 | <PromptInput | |
| 140 | turnActive={false} | |
| 141 | commands={[]} | |
| 142 | onSend={() => {}} | |
| 143 | onCancel={() => {}} | |
| 144 | />, | |
| 145 | ); | |
| 146 | const input = screen.getByLabelText("prompt") as HTMLTextAreaElement; | |
| 147 | ||
| 148 | type(input, "@ma"); | |
| 149 | await settle(); | |
| 150 | expect(screen.getByRole("listbox")).toBeDefined(); | |
| 151 | fireEvent.keyDown(input, { key: "Escape" }); | |
| 152 | expect(screen.queryByRole("listbox")).toBeNull(); | |
| 153 | ||
| 154 | type(input, "@mai"); | |
| 155 | await settle(); | |
| 156 | expect(screen.queryByRole("listbox")).toBeNull(); | |
| 157 | }); | |
| 158 | ||
| 159 | it("does not open the popup for an @ inside a word", async () => { | |
| 160 | render( | |
| 161 | <PromptInput | |
| 162 | turnActive={false} | |
| 163 | commands={[]} | |
| 164 | onSend={() => {}} | |
| 165 | onCancel={() => {}} | |
| 166 | />, | |
| 167 | ); | |
| 168 | type(screen.getByLabelText("prompt") as HTMLTextAreaElement, "me@example"); | |
| 169 | await settle(); | |
| 170 | expect(searchFilesMock).not.toHaveBeenCalled(); | |
| 171 | expect(screen.queryByRole("listbox")).toBeNull(); | |
| 172 | }); | |
| 173 | ||
| 174 | it("opens the skill popup on a leading slash, merging skills and agent commands", async () => { | |
| 175 | const onSend = vi.fn(); | |
| 176 | render( | |
| 177 | <PromptInput | |
| 178 | turnActive={false} | |
| 179 | commands={[{ name: "compact", description: "Summarise the thread" }]} | |
| 180 | onSend={onSend} | |
| 181 | onCancel={() => {}} | |
| 182 | />, | |
| 183 | ); | |
| 184 | const input = screen.getByLabelText("prompt") as HTMLTextAreaElement; | |
| 185 | ||
| 186 | type(input, "/"); | |
| 187 | await settle(); | |
| 188 | const options = screen.getAllByRole("option"); | |
| 189 | expect( | |
| 190 | options.map((o) => o.querySelector(".completion-label")?.textContent), | |
| 191 | ).toEqual(["compact", "quality"]); | |
| 192 | expect(options[0].querySelector(".completion-source")?.textContent).toBe( | |
| 193 | "command", | |
| 194 | ); | |
| 195 | expect(options[1].querySelector(".completion-source")?.textContent).toBe( | |
| 196 | "skill", | |
| 197 | ); | |
| 198 | expect(screen.getByText("Measure code quality")).toBeDefined(); | |
| 199 | ||
| 200 | type(input, "/qua"); | |
| 201 | expect(screen.getAllByRole("option")).toHaveLength(1); | |
| 202 | ||
| 203 | fireEvent.mouseDown(screen.getByRole("option")); | |
| 204 | expect(input.value).toBe("/quality "); | |
| 205 | ||
| 206 | type(input, "/quality src"); | |
| 207 | fireEvent.keyDown(input, { key: "Enter" }); | |
| 208 | expect(onSend).toHaveBeenCalledWith("/quality src", []); | |
| 209 | }); | |
| 210 | ||
| 211 | it("shows Stop instead of Send while a turn runs", () => { | |
| 212 | const onCancel = vi.fn(); | |
| 213 | render( | |
| 214 | <PromptInput | |
| 215 | turnActive={true} | |
| 216 | commands={[]} | |
| 217 | onSend={() => {}} | |
| 218 | onCancel={onCancel} | |
| 219 | />, | |
| 220 | ); | |
| 221 | fireEvent.click(screen.getByRole("button", { name: "Stop" })); | |
| 222 | expect(onCancel).toHaveBeenCalled(); | |
| 223 | }); | |
| 224 | }); |