nandi/oripublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

PromptInput.test.tsx · 224 lines · 6.0 KBTypeScript Blame HistoryRaw
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday1import { act, fireEvent, render, screen } from "@testing-library/react";
2import { beforeEach, describe, expect, it, vi } from "vitest";
3import { PromptInput } from "./PromptInput";
4
5const { searchFilesMock, listSkillsMock } = vi.hoisted(() => ({
6 searchFilesMock: vi.fn(),
7 listSkillsMock: vi.fn(),
8}));
9
10vi.mock("../api", () => ({
11 searchFiles: searchFilesMock,
12 listSkills: listSkillsMock,
13}));
14
15function type(textarea: HTMLTextAreaElement, value: string) {
16 fireEvent.change(textarea, { target: { value } });
17}
18
19async 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
26describe("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});