import { describe, expect, mock, test } from "bun:test"; import * as React from "react"; import { imageFileKey } from "@/lib/multi-image"; import { createClipboardData, createDocumentKeydownEvent, getDocumentListener, getDocumentListenerMocks, registerAIToolbarTestHooks, resetRegisteredDocumentListeners, renderToolbar, renderToolbarBoundary, } from "./helpers/ai-toolbar-test-helpers"; registerAIToolbarTestHooks(); describe("AI toolbar paste capture", () => { test("textarea paste path forwards clipboard images to onImagesSelect through the component boundary", async () => { const onImagesSelect = mock(); const image = new File(["image-bytes"], "clipboard.png", { type: "image/png", }); const { textareaProps } = await renderToolbarBoundary({ onImagesSelect }); const preventDefault = mock(); await textareaProps.onPaste?.({ clipboardData: createClipboardData([image]), preventDefault, } as unknown as React.ClipboardEvent); await new Promise((resolve) => setTimeout(resolve, 0)); expect(preventDefault).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledTimes(1); expect(onImagesSelect.mock.calls[0]?.[0][0]?.name).toMatch( /^clipboard-image-/, ); }); test("textarea-targeted paste bypasses the document listeners so the focused textarea owns the paste", async () => { const onImagesSelect = mock(); const image = new File(["image-bytes"], "clipboard.png", { type: "image/png", }); const { textareaProps } = await renderToolbarBoundary({ onImagesSelect }); const handleCapturePaste = getDocumentListener( "paste", (entry) => entry.options?.capture === true, ); const handleDocumentPaste = getDocumentListener( "paste", (entry) => !entry.options?.capture, ); const preventDefault = mock(); const textareaTarget = { tagName: "TEXTAREA", isContentEditable: false, } as HTMLTextAreaElement; const pasteEvent = { target: textareaTarget, currentTarget: textareaTarget, clipboardData: createClipboardData([image]), preventDefault, }; handleCapturePaste(pasteEvent as unknown as Event); expect(preventDefault).not.toHaveBeenCalled(); expect(onImagesSelect).not.toHaveBeenCalled(); await textareaProps.onPaste?.( pasteEvent as unknown as React.ClipboardEvent, ); await new Promise((resolve) => setTimeout(resolve, 0)); expect(preventDefault).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledTimes(1); expect(onImagesSelect.mock.calls[0]?.[0][0]?.name).toMatch( /^clipboard-image-/, ); handleDocumentPaste(pasteEvent as unknown as Event); expect(preventDefault).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledTimes(1); }); test("document paste forwards clipboard images only for non-editable targets", async () => { const onImagesSelect = mock(); const image = new File(["image-bytes"], "clipboard.png", { type: "image/png", }); await renderToolbar({ onImagesSelect }); const handleDocumentPaste = getDocumentListener( "paste", (entry) => !entry.options?.capture, ); const preventDefault = mock(); handleDocumentPaste({ target: { tagName: "DIV", isContentEditable: false }, clipboardData: createClipboardData([image]), preventDefault, } as unknown as Event); await new Promise((resolve) => setTimeout(resolve, 0)); expect(preventDefault).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledTimes(1); expect(onImagesSelect.mock.calls[0]?.[0][0]?.name).toMatch( /^clipboard-image-/, ); }); test("Ctrl/Cmd+V fallback forwards clipboard images for non-editable targets", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => [ { types: ["image/png"], getType: async () => new Blob(["image-bytes"], { type: "image/png" }), }, ]); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown({ key: "v", ctrlKey: true, metaKey: false, shiftKey: false, altKey: false, target: { tagName: "DIV", isContentEditable: false }, } as unknown as Event); expect(clipboardRead).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledTimes(1); expect(onImagesSelect.mock.calls[0]?.[0]).toHaveLength(1); expect(onImagesSelect.mock.calls[0]?.[0][0]).toBeInstanceOf(File); expect(onImagesSelect.mock.calls[0]?.[0][0]?.type).toBe("image/png"); }); test("Cmd+V fallback forwards clipboard images for non-editable targets", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => [ { types: ["image/png"], getType: async () => new Blob(["image-bytes"], { type: "image/png" }), }, ]); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown(createDocumentKeydownEvent({ metaKey: true })); expect(clipboardRead).toHaveBeenCalledTimes(1); expect(onImagesSelect).toHaveBeenCalledTimes(1); }); test("Shift+Mod+V does not trigger the async clipboard fallback", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => []); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown( createDocumentKeydownEvent({ ctrlKey: true, shiftKey: true }), ); expect(clipboardRead).not.toHaveBeenCalled(); expect(onImagesSelect).not.toHaveBeenCalled(); }); test("Alt+Mod+V does not trigger the async clipboard fallback", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => []); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown( createDocumentKeydownEvent({ ctrlKey: true, altKey: true }), ); expect(clipboardRead).not.toHaveBeenCalled(); expect(onImagesSelect).not.toHaveBeenCalled(); }); test("async clipboard fallback ignores editable targets", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => []); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown( createDocumentKeydownEvent({ ctrlKey: true, target: { tagName: "DIV", isContentEditable: true }, }), ); expect(clipboardRead).not.toHaveBeenCalled(); expect(onImagesSelect).not.toHaveBeenCalled(); }); test("Ctrl+Meta+V does not trigger the async clipboard fallback", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => []); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown( createDocumentKeydownEvent({ ctrlKey: true, metaKey: true }), ); expect(clipboardRead).not.toHaveBeenCalled(); expect(onImagesSelect).not.toHaveBeenCalled(); }); test("async clipboard fallback ignores clipboard.read() rejections", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => { throw new Error("permission denied"); }); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown(createDocumentKeydownEvent({ ctrlKey: true })); expect(clipboardRead).toHaveBeenCalledTimes(1); expect(onImagesSelect).not.toHaveBeenCalled(); }); test("async clipboard fallback ignores clipboard items when every getType probe misses", async () => { const onImagesSelect = mock(); const clipboardItem = { types: ["image/png"], getType: mock(async () => { throw new Error("NotFoundError"); }), }; const clipboardRead = mock(async () => [clipboardItem]); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown(createDocumentKeydownEvent({ ctrlKey: true })); expect(clipboardRead).toHaveBeenCalledTimes(1); expect(clipboardItem.getType).toHaveBeenCalledTimes(6); expect(onImagesSelect).not.toHaveBeenCalled(); }); test("clipboard fallback creates distinct files for same-sized clipboard images", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => [ { types: ["image/png"], getType: async () => new Blob(["abcd"], { type: "image/png" }), }, { types: ["image/png"], getType: async () => new Blob(["wxyz"], { type: "image/png" }), }, ]); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown(createDocumentKeydownEvent({ ctrlKey: true })); expect(onImagesSelect).toHaveBeenCalledTimes(1); expect(onImagesSelect.mock.calls[0]?.[0]).toHaveLength(2); expect(onImagesSelect.mock.calls[0]?.[0][0]?.name).not.toBe( onImagesSelect.mock.calls[0]?.[0][1]?.name, ); expect(onImagesSelect.mock.calls[0]?.[0][0]?.size).toBe( onImagesSelect.mock.calls[0]?.[0][1]?.size, ); }); test("clipboard fallback reuses the same synthesized file name for identical clipboard content", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => [ { types: ["image/png"], getType: async () => new Blob(["same-image"], { type: "image/png" }), }, ]); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown(createDocumentKeydownEvent({ ctrlKey: true })); await handleDocumentKeydown(createDocumentKeydownEvent({ ctrlKey: true })); expect(onImagesSelect).toHaveBeenCalledTimes(2); expect(onImagesSelect.mock.calls[0]?.[0][0]?.name).toBe( onImagesSelect.mock.calls[1]?.[0][0]?.name, ); }); test("clipboard fallback keeps the same synthesized identity across MIME aliases for identical content", async () => { const onImagesSelect = mock(); const reads = [ async () => [ { types: ["image/png"], getType: async () => new Blob(["same-image"], { type: "image/png" }), }, ], async () => [ { types: ["image/x-png"], getType: async () => new Blob(["same-image"], { type: "image/x-png" }), }, ], ]; const clipboardRead = mock(async () => (await reads.shift()?.()) ?? []); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown(createDocumentKeydownEvent({ ctrlKey: true })); await handleDocumentKeydown(createDocumentKeydownEvent({ ctrlKey: true })); expect(onImagesSelect).toHaveBeenCalledTimes(2); expect(onImagesSelect.mock.calls[0]?.[0][0]?.name).toBe( onImagesSelect.mock.calls[1]?.[0][0]?.name, ); }); test("synchronous paste and async fallback normalize identical clipboard payloads to the same file identity", async () => { const syncOnImagesSelect = mock(); const asyncOnImagesSelect = mock(); const clipboardRead = mock(async () => [ { types: ["image/png"], getType: async () => new Blob(["same-image"], { type: "image/png" }), }, ]); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; const { textareaProps } = await renderToolbarBoundary({ onImagesSelect: syncOnImagesSelect, }); await textareaProps.onPaste?.({ clipboardData: createClipboardData([ new File(["same-image"], "clipboard.png", { type: "image/png" }), ]), preventDefault: mock(), } as unknown as React.ClipboardEvent); await new Promise((resolve) => setTimeout(resolve, 0)); resetRegisteredDocumentListeners(); const documentListeners = getDocumentListenerMocks(); documentListeners.addEventListener.mockClear(); documentListeners.removeEventListener.mockClear(); await renderToolbar({ onImagesSelect: asyncOnImagesSelect }); const handleDocumentKeydown = getDocumentListener("keydown"); await handleDocumentKeydown(createDocumentKeydownEvent({ ctrlKey: true })); expect(syncOnImagesSelect).toHaveBeenCalledTimes(1); expect(asyncOnImagesSelect).toHaveBeenCalledTimes(1); expect(syncOnImagesSelect.mock.calls[0]?.[0][0]?.name).toBe( asyncOnImagesSelect.mock.calls[0]?.[0][0]?.name, ); expect(syncOnImagesSelect.mock.calls[0]?.[0][0]?.size).toBe( asyncOnImagesSelect.mock.calls[0]?.[0][0]?.size, ); expect(syncOnImagesSelect.mock.calls[0]?.[0][0]?.lastModified).toBe( asyncOnImagesSelect.mock.calls[0]?.[0][0]?.lastModified, ); expect(imageFileKey(syncOnImagesSelect.mock.calls[0]?.[0][0])).toBe( imageFileKey(asyncOnImagesSelect.mock.calls[0]?.[0][0]), ); }); test("async clipboard fallback does not double-handle a paste already handled by the synchronous document paste flow", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => [ { types: ["image/png"], getType: async () => new Blob(["image-bytes"], { type: "image/png" }), }, ]); (globalThis as { navigator?: Navigator }).navigator = { clipboard: { read: clipboardRead }, } as Navigator; await renderToolbar({ onImagesSelect }); const handleCapturePaste = getDocumentListener( "paste", (entry) => entry.options?.capture === true, ); const handleDocumentPaste = getDocumentListener( "paste", (entry) => !entry.options?.capture, ); const handleDocumentKeydown = getDocumentListener("keydown"); const image = new File(["image-bytes"], "clipboard.png", { type: "image/png", }); const preventDefault = mock(); const keydownPromise = handleDocumentKeydown( createDocumentKeydownEvent({ ctrlKey: true }), ); const pasteEvent = { target: { tagName: "DIV", isContentEditable: false }, clipboardData: createClipboardData([image]), preventDefault, } as unknown as Event; handleCapturePaste(pasteEvent); handleDocumentPaste(pasteEvent); await keydownPromise; expect(preventDefault).toHaveBeenCalledTimes(1); expect(clipboardRead).not.toHaveBeenCalled(); expect(onImagesSelect).toHaveBeenCalledTimes(1); expect(onImagesSelect.mock.calls[0]?.[0][0]?.name).toMatch( /^clipboard-image-/, ); }); test("global paste listeners only arm when AI paste capture is usable, and disarm on cleanup", async () => { await renderToolbar(); const documentListeners = getDocumentListenerMocks(); expect(documentListeners.addEventListener).toHaveBeenCalledTimes(3); expect(documentListeners.addEventListener).toHaveBeenCalledWith( "paste", expect.any(Function), ); expect(documentListeners.addEventListener).toHaveBeenCalledWith( "paste", expect.any(Function), { capture: true }, ); expect(documentListeners.addEventListener).toHaveBeenCalledWith( "keydown", expect.any(Function), ); documentListeners.lastEffectCleanup?.(); expect(documentListeners.removeEventListener).toHaveBeenCalledTimes(3); expect(documentListeners.removeEventListener).toHaveBeenCalledWith( "paste", expect.any(Function), ); expect(documentListeners.removeEventListener).toHaveBeenCalledWith( "paste", expect.any(Function), { capture: true }, ); expect(documentListeners.removeEventListener).toHaveBeenCalledWith( "keydown", expect.any(Function), ); }); test("global paste listeners stay disarmed when AI cannot be used", async () => { await renderToolbar({ isAuthenticated: false }); await renderToolbar({ isPending: true }); await renderToolbar({ adminAiEnabled: false }); await renderToolbar({ aiEnabled: false }); await renderToolbar({ aiLoading: true }); const documentListeners = getDocumentListenerMocks(); expect(documentListeners.addEventListener).not.toHaveBeenCalled(); expect(documentListeners.removeEventListener).not.toHaveBeenCalled(); }); });