| 1 | // @vitest-environment jsdom |
| 2 | |
| 3 | import { act, createRef, StrictMode, useEffect, useLayoutEffect, useState } from "react"; |
| 4 | import { createRoot, type Root } from "react-dom/client"; |
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | |
| 7 | import { |
| 8 | createMemoryHistory, |
| 9 | createRootRoute, |
| 10 | createRouter, |
| 11 | RouterProvider, |
| 12 | } from "@tanstack/react-router"; |
| 13 | import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; |
| 14 | import { collapseExpandedComposerCursor } from "~/composer-logic"; |
| 15 | import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; |
| 16 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 17 | import { ComposerContextActionsContext } from "./composerContextPresentation"; |
| 18 | |
| 19 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; |
| 20 | import { EditorView } from "@codemirror/view"; |
| 21 | import { PREPARE_UI_REFRESH_EVENT } from "~/uiRefresh"; |
| 22 | |
| 23 | let root: Root; |
| 24 | let container: HTMLDivElement; |
| 25 | const handle = createRef<ComposerPromptEditorHandle>(); |
| 26 | let replace: (value: string, cursor: number) => void; |
| 27 | let enableRichText: (enabled: boolean) => void; |
| 28 | const openMention = vi.fn(); |
| 29 | const commandKey = vi.fn((_key: string, _event: KeyboardEvent) => false); |
| 30 | const changes: Array<{ value: string; suppressed: boolean }> = []; |
| 31 | |
| 32 | function 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 | |
| 85 | beforeEach(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 | |
| 105 | afterEach(async () => { |
| 106 | await act(() => root.unmount()); |
| 107 | container.remove(); |
| 108 | sessionStorage.clear(); |
| 109 | vi.restoreAllMocks(); |
| 110 | vi.unstubAllGlobals(); |
| 111 | }); |
| 112 | |
| 113 | it("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 | |
| 148 | describe("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 | |
| 209 | it("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 | |
| 229 | it("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 | |
| 241 | it("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 | |
| 281 | it("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 | }); |