1// @vitest-environment jsdom
2
3import { act, createRef, StrictMode, useEffect, useLayoutEffect, useState } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import {
8 createMemoryHistory,
9 createRootRoute,
10 createRouter,
11 RouterProvider,
12} from "@tanstack/react-router";
13import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
14import { collapseExpandedComposerCursor } from "~/composer-logic";
15import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
16import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
17import { ComposerContextActionsContext } from "./composerContextPresentation";
18
19import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
20import { EditorView } from "@codemirror/view";
21import { PREPARE_UI_REFRESH_EVENT } from "~/uiRefresh";
22
23let root: Root;
24let container: HTMLDivElement;
25const handle = createRef<ComposerPromptEditorHandle>();
26let replace: (value: string, cursor: number) => void;
27let enableRichText: (enabled: boolean) => void;
28const openMention = vi.fn();
29const commandKey = vi.fn((_key: string, _event: KeyboardEvent) => false);
30const changes: Array<{ value: string; suppressed: boolean }> = [];
31
32function ComposerProbe({
33 focusOnMount = false,
34 initiallyEmpty = false,
35}: {
36 focusOnMount?: boolean;
37 initiallyEmpty?: boolean;
38}) {
39 const [richText, setRichText] = useState(false);
40 const [draft, setDraft] = useState({
41 value: initiallyEmpty ? "" : "before :dizzy after",
42 cursor: initiallyEmpty ? 0 : "before :dizzy".length,
43 });
44 useLayoutEffect(() => {
45 replace = (value, cursor) => setDraft({ value, cursor });
46 enableRichText = setRichText;
47 });
48 useEffect(() => {
49 if (!focusOnMount) return;
50 // oxlint-disable-next-line react/set-state-in-effect -- Reproduce the parent's mount-time cursor reset.
51 setDraft((current) => ({ ...current, cursor: current.value.length }));
52 handle.current?.focusAtEnd();
53 }, [focusOnMount]);
54 return (
55 <ComposerContextActionsContext
56 value={{
57 environmentId: null,
58 openMention,
59 expandImage: () => {},
60 expandVideo: () => {},
61 openFile: () => {},
62 openPullRequest: () => {},
63 }}
64 >
65 <ComposerPromptEditor
66 persistenceKey="refresh-test"
67 {...draft}
68 richTextEnabled={richText}
69 editorRef={handle}
70 contextRecords={new Map()}
71 skills={[]}
72 disabled={false}
73 placeholder="Message"
74 onPaste={() => {}}
75 onCommandKeyDown={commandKey}
76 onChange={(value, cursor, _expanded, suppressed) => {
77 changes.push({ value, suppressed });
78 setDraft({ value, cursor });
79 }}
80 />
81 </ComposerContextActionsContext>
82 );
83}
84
85beforeEach(async () => {
86 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
87 Object.defineProperty(Range.prototype, "getClientRects", {
88 configurable: true,
89 value: () => [],
90 });
91 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
92 configurable: true,
93 value: () => new DOMRect(),
94 });
95 container = document.createElement("div");
96 document.body.append(container);
97 root = createRoot(container);
98 changes.length = 0;
99 openMention.mockClear();
100 commandKey.mockReset();
101 commandKey.mockReturnValue(false);
102 await act(() => root.render(<ComposerProbe />));
103});
104
105afterEach(async () => {
106 await act(() => root.unmount());
107 container.remove();
108 sessionStorage.clear();
109 vi.restoreAllMocks();
110 vi.unstubAllGlobals();
111});
112
113it("restores the real editor selection and viewport after a refresh remount", async () => {
114 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
115 const view = EditorView.findFromDOM(textbox)!;
116 await act(() => view.dispatch({ selection: { anchor: 2, head: 6 } }));
117 view.scrollDOM.scrollTop = 35;
118 expect(window.dispatchEvent(new Event(PREPARE_UI_REFRESH_EVENT, { cancelable: true }))).toBe(
119 true,
120 );
121 vi.spyOn(performance, "timeOrigin", "get").mockReturnValue(performance.timeOrigin + 1);
122 await act(() => root.unmount());
123 root = createRoot(container);
124 await act(() =>
125 root.render(
126 <StrictMode>
127 <ComposerProbe focusOnMount initiallyEmpty />
128 </StrictMode>,
129 ),
130 );
131 await act(() => replace("before :dizzy after", "before :dizzy after".length));
132 expect(handle.current!.readSelectionRange()).toEqual({ start: 2, end: 6 });
133 expect(document.getSelection()?.toString()).toBe("fore");
134 const restored = EditorView.findFromDOM(
135 container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!,
136 )!;
137 expect(restored.scrollDOM.scrollTop).toBe(35);
138 await act(() => {
139 restored.contentDOM.dispatchEvent(
140 new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }),
141 );
142 handle.current!.focusAtEnd();
143 });
144 const end = handle.current!.readSnapshot().value.length;
145 expect(handle.current!.readSelectionRange()).toEqual({ start: end, end });
146});
147
148describe("emoji completion in the real composer editor", () => {
149 it("keeps the caret and undo history when a controlled completion replaces the shortcode", async () => {
150 await act(() => handle.current!.focus());
151 expect(handle.current!.readSnapshot().expandedCursor).toBe("before :dizzy".length);
152 await act(() => replace("before 💫 after", "before 💫".length));
153 expect(handle.current!.readSnapshot()).toMatchObject({
154 value: "before 💫 after",
155 expandedCursor: "before 💫".length,
156 });
157 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
158 expect(document.activeElement).toBe(textbox);
159 await act(() =>
160 textbox.dispatchEvent(
161 new KeyboardEvent("keydown", {
162 key: "z",
163 code: "KeyZ",
164 ctrlKey: true,
165 bubbles: true,
166 cancelable: true,
167 }),
168 ),
169 );
170 expect(handle.current!.readSnapshot().value).toBe("before :dizzy after");
171 });
172
173 it("suppresses suggestions when a text selection keeps its anchor at the same caret", async () => {
174 await act(() => handle.current!.focus());
175 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
176 const selection = window.getSelection()!;
177 const range = selection.getRangeAt(0);
178 const selected = document.createRange();
179 selected.setStart(range.startContainer, range.startOffset);
180 selected.setEnd(range.startContainer, range.startOffset + 2);
181 await act(async () => {
182 selection.removeAllRanges();
183 selection.addRange(selected);
184 document.dispatchEvent(new Event("selectionchange"));
185 await Promise.resolve();
186 });
187 expect(textbox.contains(selection.anchorNode)).toBe(true);
188 expect(handle.current!.suggestionsSuppressed()).toBe(true);
189 expect(changes.at(-1)).toMatchObject({ suppressed: true });
190 });
191
192 it("closes suggestions during composition and rechecks the committed caret afterward", async () => {
193 await act(() => handle.current!.focus());
194 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
195 await act(() =>
196 textbox.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })),
197 );
198 expect(changes.at(-1)).toMatchObject({ suppressed: true });
199 expect(handle.current!.suggestionsSuppressed()).toBe(true);
200 await act(async () => {
201 textbox.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
202 await Promise.resolve();
203 });
204 expect(handle.current!.suggestionsSuppressed()).toBe(false);
205 expect(changes.at(-1)).toMatchObject({ value: "before :dizzy after", suppressed: false });
206 });
207});
208
209it("changes a real fence language picker while preserving metadata, code, and editor focus", async () => {
210 const draft = "```ts title=x\nconst x = 1\n```";
211 await act(() => {
212 enableRichText(true);
213 replace(draft, draft.length);
214 });
215 const trigger = container.querySelector<HTMLButtonElement>(
216 'button[aria-label="Code language: TypeScript"]',
217 )!;
218 expect(trigger).not.toBeNull();
219 await act(() => trigger.click());
220 const python = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find((option) =>
221 option.textContent?.includes("Python"),
222 )!;
223 expect(python).toBeDefined();
224 await act(() => python.click());
225 expect(handle.current!.readSnapshot().value).toBe("```python title=x\nconst x = 1\n```");
226 expect(document.activeElement).toBe(container.querySelector('[data-testid="composer-editor"]'));
227});
228
229it("opens a real file chip through its application action provider", async () => {
230 const source = serializeComposerFileLink("src/example.ts");
231 await act(() => replace(`${source} after`, `${source} `.length));
232 const button = container.querySelector<HTMLButtonElement>(
233 '[aria-label="Preview src/example.ts"]',
234 )!;
235 expect(button).not.toBeNull();
236 await act(() => button.click());
237 expect(openMention).toHaveBeenCalledWith("src/example.ts");
238 expect(handle.current!.readSnapshot().value).toBe(`${source} after`);
239});
240
241it("copies canonical chip Markdown, cuts once, and restores it with native undo", async () => {
242 const source = `${serializeComposerFileLink("src/example.ts")} after`;
243 await act(() => {
244 replace(source, source.length);
245 handle.current!.focus();
246 });
247 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
248 await act(() =>
249 textbox.dispatchEvent(
250 new KeyboardEvent("keydown", {
251 key: "a",
252 ctrlKey: true,
253 bubbles: true,
254 cancelable: true,
255 }),
256 ),
257 );
258 expect(handle.current!.readSelectionRange()).toEqual({ start: 0, end: source.length });
259 const data = new Map<string, string>();
260 const event = new Event("cut", { bubbles: true, cancelable: true });
261 Object.defineProperty(event, "clipboardData", {
262 value: { setData: (kind: string, text: string) => data.set(kind, text) },
263 });
264 await act(() => textbox.dispatchEvent(event));
265 expect(event.defaultPrevented).toBe(true);
266 expect(data.get("text/plain")).toBe(source);
267 expect(handle.current!.readSnapshot().value).toBe("");
268 await act(() =>
269 textbox.dispatchEvent(
270 new KeyboardEvent("keydown", {
271 key: "z",
272 ctrlKey: true,
273 bubbles: true,
274 cancelable: true,
275 }),
276 ),
277 );
278 expect(handle.current!.readSnapshot().value).toBe(source);
279});
280
281it("Shift+Tabs from a citation caret to its real comment trigger before plan-mode handling", async () => {
282 const router = createRouter({
283 routeTree: createRootRoute({ component: ComposerProbe }),
284 history: createMemoryHistory({ initialEntries: ["/"] }),
285 });
286 await router.load();
287 await act(() => root.render(<RouterProvider router={router} />));
288 const citation = serializeAssistantCitation({
289 version: 1,
290 environmentId: EnvironmentId.make("env"),
291 threadId: ThreadId.make("thread"),
292 messageId: MessageId.make("message"),
293 text: "Quoted text",
294 start: 0,
295 end: 11,
296 prefix: "",
297 suffix: "",
298 });
299 const source = `${citation} after`;
300 await act(() => {
301 replace(source, collapseExpandedComposerCursor(source, citation.length));
302 });
303 await act(() => handle.current!.focus());
304 expect(handle.current!.readSnapshot().expandedCursor).toBe(citation.length);
305 commandKey.mockImplementation((key, event) => key === "Tab" && event.shiftKey);
306 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
307 const commentButton = container.querySelector<HTMLElement>("[data-citation-comment-trigger]")!;
308 expect(commentButton).not.toBeNull();
309 const shiftTab = new KeyboardEvent("keydown", {
310 key: "Tab",
311 shiftKey: true,
312 bubbles: true,
313 cancelable: true,
314 });
315 await act(() => textbox.dispatchEvent(shiftTab));
316 expect(shiftTab.defaultPrevented).toBe(true);
317 expect(document.activeElement).toBe(commentButton);
318 expect(commandKey).not.toHaveBeenCalled();
319 expect(handle.current!.readSnapshot()).toMatchObject({
320 value: source,
321 expandedCursor: citation.length,
322 });
323 const tab = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
324 await act(() => commentButton.dispatchEvent(tab));
325 expect(tab.defaultPrevented).toBe(true);
326 expect(document.activeElement).toBe(textbox);
327 expect(handle.current!.readSnapshot().expandedCursor).toBe(citation.length);
328 await act(() => handle.current!.focusAtEnd());
329 await act(() =>
330 textbox.dispatchEvent(
331 new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
332 ),
333 );
334 expect(commandKey).toHaveBeenCalledTimes(1);
335});