/** * @vitest-environment jsdom */ import { act, useEffect } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $createParagraphNode, $getRoot, type LexicalEditor } from "lexical"; import { LexicalComposerInput } from "./LexicalComposerInput"; const setText = vi.fn<(text: string) => void>(); const sendSpy = vi.fn<(options?: { steer?: boolean }) => void>(); const cancelSpy = vi.fn<() => void>(); const pluginHandleKeyDown = vi.fn<(event: KeyboardEvent) => boolean>(); const setCursorPosition = vi.fn<(position: number) => void>(); const registerInput = vi.fn(() => () => {}); const composerState = { isEditing: true, text: "", type: "thread" as const, isEmpty: true, canCancel: false, canSend: true, dictation: undefined as undefined | { inputDisabled: boolean }, }; const threadState = { isDisabled: false, isRunning: false, }; (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; vi.mock("@assistant-ui/store", async (importOriginal) => { const actual = await importOriginal(); const aui = { composer: { setText: (text: string) => setText(text), getState: () => composerState, cancel: () => cancelSpy(), send: () => sendSpy(), }, thread: { getState: () => threadState, }, on: () => () => {}, }; type Selector = (s: { composer: typeof composerState; thread: typeof threadState; }) => T; return { ...actual, useAui: () => aui, useAuiState: (selector: Selector) => selector({ composer: composerState, thread: threadState }), }; }); vi.mock("@assistant-ui/react", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, INTERNAL: { ...actual.INTERNAL, useComposerInputPluginRegistryOptional: () => ({ getPlugins: () => [ { handleKeyDown: pluginHandleKeyDown, setCursorPosition, }, ], registerInput, }), }, }; }); describe("LexicalComposerInput", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { setText.mockReset(); sendSpy.mockReset(); cancelSpy.mockReset(); pluginHandleKeyDown.mockReset(); pluginHandleKeyDown.mockReturnValue(false); setCursorPosition.mockReset(); registerInput.mockClear(); composerState.isEditing = true; composerState.text = ""; composerState.isEmpty = true; composerState.canSend = true; composerState.dictation = undefined; threadState.isDisabled = false; threadState.isRunning = false; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); afterEach(async () => { await act(async () => { root.unmount(); }); container.remove(); vi.restoreAllMocks(); }); it("renders children inside the LexicalComposer context with a live editor", async () => { let capturedEditor: LexicalEditor | null = null; let updateListenerFired = false; function ProbePlugin() { const [editor] = useLexicalComposerContext(); useEffect(() => { capturedEditor = editor; return editor.registerUpdateListener(() => { updateListenerFired = true; }); }, [editor]); return null; } await act(async () => { root.render( , ); }); expect(capturedEditor).not.toBeNull(); await act(async () => { capturedEditor!.update(() => { $getRoot().append($createParagraphNode()); }); }); expect(updateListenerFired).toBe(true); }); it("still renders the built-in contentEditable alongside children", async () => { function ProbePlugin() { useLexicalComposerContext(); return null; } await act(async () => { root.render( , ); }); expect(container.querySelector(".aui-lexical-input")).not.toBeNull(); }); it("delegates Tab to composer input plugins", async () => { await act(async () => { root.render(); }); const input = container.querySelector(".aui-lexical-input"); expect(input).not.toBeNull(); await act(async () => { input!.dispatchEvent( new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true, }), ); }); expect(pluginHandleKeyDown).toHaveBeenCalledOnce(); expect(pluginHandleKeyDown.mock.calls[0]![0].key).toBe("Tab"); }); });