import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ComposerInput } from "./ComposerInput"; const h = vi.hoisted(() => ({ setText: vi.fn<(text: string) => void>(), sendSpy: vi.fn<() => void>(), flushTapSyncSpy: vi.fn((fn: () => T) => fn()), composerState: { text: "" }, platform: { os: "web" as "web" | "ios" | "android" }, })); vi.mock("@assistant-ui/store", () => { const composer = Object.assign(() => composer, { setText: h.setText, send: h.sendSpy, }); const aui = { composer }; return { useAui: () => aui, useAuiState: ( selector: (s: { composer: typeof h.composerState }) => T, ) => selector({ composer: h.composerState }), }; }); vi.mock("@assistant-ui/tap", () => ({ flushTapSync: h.flushTapSyncSpy, })); vi.mock("react-native", async (importOriginal) => { const actual = (await importOriginal()) as Record & { Platform: Record; }; return { ...actual, Platform: { ...actual.Platform, get OS() { return h.platform.os; }, select: (obj: Record) => h.platform.os in obj ? obj[h.platform.os] : obj.default, }, }; }); (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; const setNativeValue = ( input: HTMLInputElement | HTMLTextAreaElement, value: string, ) => { const prototype = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; setter?.call(input, value); }; const fireInput = ( input: HTMLInputElement | HTMLTextAreaElement, value: string, ) => { setNativeValue(input, value); input.dispatchEvent(new InputEvent("input", { bubbles: true })); }; const fireKeyDown = ( input: HTMLInputElement | HTMLTextAreaElement, opts: { key?: string; shiftKey?: boolean; isComposing?: boolean; keyCode?: number; } = {}, ): KeyboardEvent => { const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: opts.key ?? "Enter", shiftKey: opts.shiftKey ?? false, isComposing: opts.isComposing ?? false, }); if (opts.keyCode !== undefined) { Object.defineProperty(event, "keyCode", { value: opts.keyCode }); } input.dispatchEvent(event); return event; }; describe("ComposerInput", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { h.setText.mockReset(); h.sendSpy.mockReset(); h.flushTapSyncSpy.mockClear(); h.composerState.text = ""; h.platform.os = "web"; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); afterEach(async () => { await act(async () => { root.unmount(); }); container.remove(); }); const mount = async ( props: Partial[0]> = {}, ) => { await act(async () => { root.render(); }); const input = container.querySelector("textarea, input") as | HTMLInputElement | HTMLTextAreaElement; expect(input).not.toBeNull(); return input; }; it("renders an input controlled by the composer text", async () => { h.composerState.text = "hello"; const input = await mount(); expect(input.value).toBe("hello"); }); describe("web", () => { it("routes typing through setText wrapped in flushTapSync", async () => { const input = await mount(); await act(async () => { fireInput(input, "ni"); }); expect(h.setText).toHaveBeenCalledWith("ni"); expect(h.flushTapSyncSpy).toHaveBeenCalledTimes(1); }); it("submits on plain Enter under the default submitMode", async () => { const input = await mount(); let event!: KeyboardEvent; await act(async () => { event = fireKeyDown(input, { key: "Enter" }); }); expect(h.sendSpy).toHaveBeenCalledTimes(1); expect(event.defaultPrevented).toBe(true); }); it("inserts a newline on Shift+Enter without submitting", async () => { const input = await mount(); let event!: KeyboardEvent; await act(async () => { event = fireKeyDown(input, { key: "Enter", shiftKey: true }); }); expect(h.sendSpy).not.toHaveBeenCalled(); expect(event.defaultPrevented).toBe(false); }); it("ignores Enter while isComposing is set", async () => { const input = await mount(); let event!: KeyboardEvent; await act(async () => { event = fireKeyDown(input, { key: "Enter", isComposing: true }); }); expect(h.sendSpy).not.toHaveBeenCalled(); expect(event.defaultPrevented).toBe(false); }); it("ignores Enter while keyCode is the IME sentinel 229", async () => { const input = await mount(); let event!: KeyboardEvent; await act(async () => { event = fireKeyDown(input, { key: "Enter", keyCode: 229 }); }); expect(h.sendSpy).not.toHaveBeenCalled(); expect(event.defaultPrevented).toBe(false); }); it("does not submit on a non-Enter key", async () => { const input = await mount(); await act(async () => { fireKeyDown(input, { key: "a" }); }); expect(h.sendSpy).not.toHaveBeenCalled(); }); it("does not submit when submitMode is none", async () => { const input = await mount({ submitMode: "none" }); let event!: KeyboardEvent; await act(async () => { event = fireKeyDown(input, { key: "Enter" }); }); expect(h.sendSpy).not.toHaveBeenCalled(); expect(event.defaultPrevented).toBe(false); }); it("shows a scrollbar when multiline content exceeds the maximum height", async () => { h.composerState.text = "line 1"; const input = await mount({ multiline: true, style: { maxHeight: 40 }, }); expect(input.tagName).toBe("TEXTAREA"); Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120, }); h.composerState.text = "line 1\nline 2\nline 3"; await act(async () => { root.render(); }); expect(input.style.height).toBe("40px"); expect(input.style.overflowY).toBe("auto"); expect(input.style.overflowX).toBe("hidden"); }); it("shrinks back below max height after content is deleted", async () => { h.composerState.text = "line 1"; const input = await mount({ multiline: true, style: { maxHeight: 40 }, }); expect(input.tagName).toBe("TEXTAREA"); let scrollHeight = 120; Object.defineProperty(input, "scrollHeight", { configurable: true, get: () => scrollHeight, }); h.composerState.text = "line 1\nline 2\nline 3"; await act(async () => { root.render(); }); expect(input.style.height).toBe("40px"); expect(input.style.overflowY).toBe("auto"); scrollHeight = 24; h.composerState.text = "line 1"; await act(async () => { root.render(); }); expect(input.style.height).toBe("24px"); expect(input.style.overflowY).toBe("hidden"); }); it("prefers computed maxHeight over raw CSS unit parsing", async () => { h.composerState.text = "line 1"; const computedStyleSpy = vi .spyOn(globalThis, "getComputedStyle") .mockImplementation((() => ({ maxHeight: "64px", })) as unknown as typeof getComputedStyle); try { const input = await mount({ multiline: true, style: { maxHeight: "4rem" as never }, }); expect(input.tagName).toBe("TEXTAREA"); Object.defineProperty(input, "scrollHeight", { configurable: true, value: 80, }); h.composerState.text = "line 1\nline 2\nline 3"; await act(async () => { root.render( , ); }); expect(input.style.height).toBe("64px"); expect(input.style.overflowY).toBe("auto"); } finally { computedStyleSpy.mockRestore(); } }); it("forwards keydown events to a consumer onKeyPress handler", async () => { const onKeyPress = vi.fn(); const input = await mount({ onKeyPress }); await act(async () => { fireKeyDown(input, { key: "Enter" }); fireKeyDown(input, { key: "a" }); }); expect(onKeyPress).toHaveBeenCalledTimes(2); }); it("does not submit when the consumer prevents the key event", async () => { const input = await mount({ onKeyPress: (event) => event.preventDefault(), }); await act(async () => { fireKeyDown(input, { key: "Enter" }); }); expect(h.sendSpy).not.toHaveBeenCalled(); }); }); describe("native", () => { beforeEach(() => { h.platform.os = "ios"; }); it("routes typing through setText without flushTapSync", async () => { const input = await mount(); await act(async () => { fireInput(input, "ni"); }); expect(h.setText).toHaveBeenCalledWith("ni"); expect(h.flushTapSyncSpy).not.toHaveBeenCalled(); }); it("never submits on Enter and leaves the event untouched", async () => { const onKeyPress = vi.fn(); const input = await mount({ onKeyPress }); let event!: KeyboardEvent; await act(async () => { event = fireKeyDown(input, { key: "Enter" }); }); expect(h.sendSpy).not.toHaveBeenCalled(); expect(event.defaultPrevented).toBe(false); expect(onKeyPress).toHaveBeenCalledTimes(1); }); }); });