/** * @vitest-environment jsdom */ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ComposerPrimitiveInput } from "./ComposerInput"; const setText = vi.fn<(text: string) => void>(); const setCursorPosition = vi.fn<(pos: number) => void>(); const composerState = { isEditing: true, text: "", type: "thread" as const, isEmpty: true, canCancel: false, dictation: undefined as undefined | { inputDisabled: boolean }, }; const threadState = { isDisabled: false, isRunning: false, capabilities: { queue: false, attachments: false }, }; const plugin = { handleKeyDown: () => false, setCursorPosition, }; let pluginRegistry: { getPlugins: () => (typeof plugin)[] } | null = null; globalThis.IS_REACT_ACT_ENVIRONMENT = true; vi.mock("@assistant-ui/store", () => { const aui = { composer: () => ({ setText, getState: () => composerState, cancel: () => {}, send: () => {}, addAttachment: async () => {}, }), thread: () => ({ getState: () => threadState, }), on: () => () => {}, }; type Selector = (s: { composer: typeof composerState; thread: typeof threadState; }) => T; return { useAui: () => aui, useAuiState: (selector: Selector) => selector({ composer: composerState, thread: threadState }), }; }); vi.mock("@assistant-ui/tap", () => ({ flushResourcesSync: (fn: () => void) => fn(), })); vi.mock("./ComposerInputPluginContext", () => ({ useComposerInputPluginRegistryOptional: () => pluginRegistry, })); let activeAria: { popoverId: string; highlightedItemId: string | undefined; } | null = null; vi.mock("./trigger/TriggerPopoverRootContext", () => ({ useTriggerPopoverActiveAriaOptional: () => activeAria, })); vi.mock("@radix-ui/react-use-escape-keydown", () => ({ useEscapeKeydown: () => {}, })); vi.mock("../../utils/hooks/useOnScrollToBottom", () => ({ useOnScrollToBottom: () => {}, })); const setNativeValue = (textarea: HTMLTextAreaElement, value: string) => { const setter = Object.getOwnPropertyDescriptor( HTMLTextAreaElement.prototype, "value", )?.set; setter?.call(textarea, value); }; const fireInput = ( textarea: HTMLTextAreaElement, value: string, isComposing: boolean, ) => { setNativeValue(textarea, value); textarea.dispatchEvent( new InputEvent("input", { bubbles: true, isComposing }), ); }; const fireCompositionStart = (textarea: HTMLTextAreaElement) => { textarea.dispatchEvent( new CompositionEvent("compositionstart", { bubbles: true }), ); }; const fireCompositionEnd = (textarea: HTMLTextAreaElement, value: string) => { setNativeValue(textarea, value); textarea.dispatchEvent( new CompositionEvent("compositionend", { bubbles: true }), ); }; describe("ComposerPrimitiveInput", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { setText.mockReset(); setCursorPosition.mockReset(); composerState.isEditing = true; composerState.text = ""; composerState.isEmpty = true; composerState.dictation = undefined; threadState.isDisabled = false; threadState.isRunning = false; threadState.capabilities = { queue: false, attachments: false }; pluginRegistry = null; activeAria = null; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); afterEach(async () => { await act(async () => { root.unmount(); }); container.remove(); vi.restoreAllMocks(); }); const mount = async () => { await act(async () => { root.render(); }); const textarea = container.querySelector("textarea") as HTMLTextAreaElement; expect(textarea).not.toBeNull(); return textarea; }; it("syncs setText during active composition so React 19 cannot reset the textarea", async () => { const textarea = await mount(); await act(async () => { fireCompositionStart(textarea); fireInput(textarea, "ㄱ", true); }); expect(setText).toHaveBeenCalledWith("ㄱ"); await act(async () => { fireInput(textarea, "가", true); }); expect(setText).toHaveBeenLastCalledWith("가"); }); it("commits the final value on compositionend", async () => { const textarea = await mount(); await act(async () => { fireCompositionStart(textarea); fireInput(textarea, "가", true); }); expect(setText).toHaveBeenCalledTimes(1); await act(async () => { fireCompositionEnd(textarea, "가"); }); expect(setText).toHaveBeenCalledTimes(2); expect(setText).toHaveBeenLastCalledWith("가"); }); it("recovers when compositionend is dropped before the next input", async () => { const textarea = await mount(); await act(async () => { fireCompositionStart(textarea); fireInput(textarea, "hello", false); }); expect(setText).toHaveBeenCalledWith("hello"); await act(async () => { fireInput(textarea, "hello!", false); }); expect(setText).toHaveBeenLastCalledWith("hello!"); }); it("skips plugin cursor tracking during composition but resumes after", async () => { pluginRegistry = { getPlugins: () => [plugin] }; const textarea = await mount(); await act(async () => { fireCompositionStart(textarea); fireInput(textarea, "ㄱ", true); }); expect(setText).toHaveBeenCalledWith("ㄱ"); expect(setCursorPosition).not.toHaveBeenCalled(); await act(async () => { fireCompositionEnd(textarea, "가"); }); expect(setCursorPosition).toHaveBeenCalled(); }); it("tracks plugin cursor for non-composition input", async () => { pluginRegistry = { getPlugins: () => [plugin] }; const textarea = await mount(); await act(async () => { fireInput(textarea, "abc", false); }); expect(setText).toHaveBeenCalledWith("abc"); expect(setCursorPosition).toHaveBeenCalled(); }); it("ignores input and compositionend when the composer is not editing", async () => { composerState.isEditing = false; const textarea = await mount(); await act(async () => { fireInput(textarea, "abc", false); fireCompositionStart(textarea); fireCompositionEnd(textarea, "가"); }); expect(setText).not.toHaveBeenCalled(); }); it("does not apply ARIA combobox attributes when no trigger popover is open", async () => { activeAria = null; const textarea = await mount(); expect(textarea.getAttribute("aria-controls")).toBeNull(); expect(textarea.getAttribute("aria-expanded")).toBeNull(); expect(textarea.getAttribute("aria-haspopup")).toBeNull(); expect(textarea.getAttribute("aria-activedescendant")).toBeNull(); }); it("applies ARIA combobox attributes when a trigger popover is open", async () => { activeAria = { popoverId: "popover-1", highlightedItemId: "popover-1-option-foo", }; const textarea = await mount(); expect(textarea.getAttribute("aria-controls")).toBe("popover-1"); expect(textarea.getAttribute("aria-expanded")).toBe("true"); expect(textarea.getAttribute("aria-haspopup")).toBe("listbox"); expect(textarea.getAttribute("aria-activedescendant")).toBe( "popover-1-option-foo", ); }); it("omits aria-activedescendant when no item is highlighted", async () => { activeAria = { popoverId: "popover-1", highlightedItemId: undefined, }; const textarea = await mount(); expect(textarea.getAttribute("aria-controls")).toBe("popover-1"); expect(textarea.getAttribute("aria-expanded")).toBe("true"); expect(textarea.getAttribute("aria-haspopup")).toBe("listbox"); expect(textarea.getAttribute("aria-activedescendant")).toBeNull(); }); });