From bd08e9fc6368fb0ab9898eabff68455f96835501 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Thu, 23 Apr 2026 05:41:43 -0400 Subject: [PATCH] test(ai-toolbar): split coverage and add fallback failure cases --- tests/ai-toolbar-layout.test.ts | 101 ++ tests/ai-toolbar-paste.test.ts | 546 +++++++++++ tests/ai-toolbar-shortcuts.test.ts | 213 +++++ tests/ai-toolbar-state.test.ts | 101 ++ tests/ai-toolbar.test.ts | 1101 ---------------------- tests/helpers/ai-toolbar-test-helpers.ts | 242 +++++ 6 files changed, 1203 insertions(+), 1101 deletions(-) create mode 100644 tests/ai-toolbar-layout.test.ts create mode 100644 tests/ai-toolbar-paste.test.ts create mode 100644 tests/ai-toolbar-shortcuts.test.ts create mode 100644 tests/ai-toolbar-state.test.ts delete mode 100644 tests/ai-toolbar.test.ts create mode 100644 tests/helpers/ai-toolbar-test-helpers.ts diff --git a/tests/ai-toolbar-layout.test.ts b/tests/ai-toolbar-layout.test.ts new file mode 100644 index 0000000..e24c4e7 --- /dev/null +++ b/tests/ai-toolbar-layout.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { + getMatchingClassTokens, + registerAIToolbarTestHooks, + renderToolbarMarkup, +} from "./helpers/ai-toolbar-test-helpers"; + +registerAIToolbarTestHooks(); + +describe("AI toolbar layout contracts", () => { + test("desktop composer uses a dedicated multi-column branch while mobile stays single-column", async () => { + const desktopMarkup = await renderToolbarMarkup(); + const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true }); + const desktopLayoutTokens = getMatchingClassTokens( + desktopMarkup, + (tokens) => + tokens.includes("grid") && + tokens.some((token) => token.startsWith("grid-cols-[minmax(0,0.7fr)")), + ); + const mobileLayoutTokens = getMatchingClassTokens( + mobileMarkup, + (tokens) => tokens.includes("grid") && tokens.includes("gap-3"), + ); + + expect(desktopLayoutTokens).toHaveLength(1); + expect(desktopMarkup).toContain("Keyboard shortcuts"); + expect(desktopMarkup).toContain("Attachments"); + expect(mobileLayoutTokens).toHaveLength(1); + expect(mobileMarkup).not.toContain("Keyboard shortcuts"); + expect( + mobileLayoutTokens[0].some((token) => token.startsWith("grid-cols-")), + ).toBe(false); + }); + + test("example prompts render as a masonry-style cluster below the textarea", async () => { + const markup = await renderToolbarMarkup(); + const masonryColumns = getMatchingClassTokens( + markup, + (tokens) => tokens.some((token) => token.startsWith("columns-")), + ); + const masonryWrappers = getMatchingClassTokens( + markup, + (tokens) => tokens.includes("break-inside-avoid"), + ); + const promptButtons = getMatchingClassTokens( + markup, + (tokens) => + tokens.includes("justify-start") && + tokens.includes("text-left") && + tokens.includes("whitespace-normal"), + ); + + expect(markup).toContain("Try:"); + expect(markup).toContain( + "Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.", + ); + expect(markup).toContain( + "Project sync tomorrow from 9am to 10am on Google Meet with a weekly repeat.", + ); + expect(markup).toContain( + "Dentist appointment on May 14 at 3pm at Smile Studio, add confirmation #A4821.", + ); + expect(masonryColumns).toHaveLength(1); + expect(masonryColumns[0]).toEqual( + expect.arrayContaining(["columns-2", "gap-2"]), + ); + expect(masonryWrappers).toHaveLength(3); + expect(promptButtons).toHaveLength(3); + }); + + test("attachments render as a separate surfaced panel with count badge, picker, and empty state", async () => { + const markup = await renderToolbarMarkup(); + + expect(markup).toContain("Attachments"); + expect(markup).toContain("0 files"); + expect(markup).toContain("Attach images"); + expect(markup).toContain( + "Drop or paste images here to pair them with the prompt.", + ); + }); + + test("attachment previews render in a stacked grid instead of a multi-column strip", async () => { + const markup = await renderToolbarMarkup({ + imagePreviews: ["blob:first", "blob:second"], + }); + const previewGridTokens = getMatchingClassTokens( + markup, + (tokens) => tokens.includes("grid") && tokens.includes("gap-2"), + ); + const multiColumnPreviewPattern = + /(?:^|\s)(?:[a-z]+:)*(?:grid-cols-(?:\[[^\]]+\]|\S+)|grid-flow-col|auto-cols-(?:\[[^\]]+\]|\S+)|columns-(?:\[[^\]]+\]|\S+)|overflow-x-auto|flex-row)/; + + expect(markup).toContain("Attached image 1"); + expect(markup).toContain("Attached image 2"); + expect( + previewGridTokens.some( + (tokens) => !tokens.join(" ").match(multiColumnPreviewPattern), + ), + ).toBe(true); + }); +}); diff --git a/tests/ai-toolbar-paste.test.ts b/tests/ai-toolbar-paste.test.ts new file mode 100644 index 0000000..7ec6834 --- /dev/null +++ b/tests/ai-toolbar-paste.test.ts @@ -0,0 +1,546 @@ +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(); + }); +}); diff --git a/tests/ai-toolbar-shortcuts.test.ts b/tests/ai-toolbar-shortcuts.test.ts new file mode 100644 index 0000000..bb01131 --- /dev/null +++ b/tests/ai-toolbar-shortcuts.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + createTextareaKeydownEvent, + registerAIToolbarTestHooks, + renderToolbarBoundary, +} from "./helpers/ai-toolbar-test-helpers"; + +registerAIToolbarTestHooks(); + +describe("AI toolbar keyboard shortcuts", () => { + test("Ctrl+Enter triggers AI generation when the prompt has content", async () => { + const onAiCreate = mock(); + + const { textareaProps } = await renderToolbarBoundary({ + aiPrompt: "Draft a kickoff", + onAiCreate, + }); + const event = createTextareaKeydownEvent({ ctrlKey: true }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(onAiCreate).toHaveBeenCalledTimes(1); + }); + + test("Cmd+Enter triggers AI generation when images are attached", async () => { + const onAiCreate = mock(); + + const { textareaProps } = await renderToolbarBoundary({ + imagePreviews: ["blob:first"], + onAiCreate, + }); + const event = createTextareaKeydownEvent({ metaKey: true }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(onAiCreate).toHaveBeenCalledTimes(1); + }); + + test("Mod+Enter ignores extra modifiers so Shift+Ctrl+Enter does not generate", async () => { + const onAiCreate = mock(); + + const { textareaProps } = await renderToolbarBoundary({ + aiPrompt: "Draft a kickoff", + onAiCreate, + }); + const event = createTextareaKeydownEvent({ + ctrlKey: true, + shiftKey: true, + }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(onAiCreate).not.toHaveBeenCalled(); + }); + + test("Mod+Enter ignores combined Ctrl+Meta modifiers", async () => { + const onAiCreate = mock(); + + const { textareaProps } = await renderToolbarBoundary({ + aiPrompt: "Draft a kickoff", + onAiCreate, + }); + const event = createTextareaKeydownEvent({ + ctrlKey: true, + metaKey: true, + }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(onAiCreate).not.toHaveBeenCalled(); + }); + + test("Mod+Enter ignores Alt-modified submissions", async () => { + const onAiCreate = mock(); + + const { textareaProps } = await renderToolbarBoundary({ + aiPrompt: "Draft a kickoff", + onAiCreate, + }); + const event = createTextareaKeydownEvent({ + ctrlKey: true, + altKey: true, + }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(onAiCreate).not.toHaveBeenCalled(); + }); + + test("Mod+Enter does not trigger AI generation while loading", async () => { + const onAiCreate = mock(); + + const { textareaProps } = await renderToolbarBoundary({ + aiPrompt: "Draft a kickoff", + aiLoading: true, + onAiCreate, + }); + const event = createTextareaKeydownEvent({ ctrlKey: true }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(onAiCreate).not.toHaveBeenCalled(); + }); + + test("Shift+Mod+A opens the image picker when the composer is idle", async () => { + const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); + const ctrlEvent = createTextareaKeydownEvent({ + key: "A", + ctrlKey: true, + shiftKey: true, + }); + + textareaProps.onKeyDown?.(ctrlEvent); + + expect(ctrlEvent.preventDefault).toHaveBeenCalledTimes(1); + expect(imageTriggerOpen).toHaveBeenCalledTimes(1); + }); + + test("Mod+A without Shift does not open the image picker", async () => { + const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); + const event = createTextareaKeydownEvent({ + key: "A", + ctrlKey: true, + }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(imageTriggerOpen).not.toHaveBeenCalled(); + }); + + test("Shift+Cmd+A also opens the image picker when the composer is idle", async () => { + const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); + const metaEvent = createTextareaKeydownEvent({ + key: "A", + metaKey: true, + shiftKey: true, + }); + + textareaProps.onKeyDown?.(metaEvent); + + expect(metaEvent.preventDefault).toHaveBeenCalledTimes(1); + expect(imageTriggerOpen).toHaveBeenCalledTimes(1); + }); + + test("Shift+Mod+A ignores Alt-modified submissions", async () => { + const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); + const event = createTextareaKeydownEvent({ + key: "A", + ctrlKey: true, + shiftKey: true, + altKey: true, + }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(imageTriggerOpen).not.toHaveBeenCalled(); + }); + + test("Shift+Mod+A ignores combined Ctrl+Meta modifiers", async () => { + const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); + const event = createTextareaKeydownEvent({ + key: "A", + ctrlKey: true, + metaKey: true, + shiftKey: true, + }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(imageTriggerOpen).not.toHaveBeenCalled(); + }); + + test("Shift+Mod+A stays disabled while generation is in progress", async () => { + const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary({ + aiLoading: true, + }); + const ctrlEvent = createTextareaKeydownEvent({ + key: "A", + ctrlKey: true, + shiftKey: true, + }); + + textareaProps.onKeyDown?.(ctrlEvent); + + expect(ctrlEvent.preventDefault).not.toHaveBeenCalled(); + expect(imageTriggerOpen).not.toHaveBeenCalled(); + }); + + test("Escape clears the prompt when the textarea has content", async () => { + const setAiPrompt = mock(); + + const { textareaProps } = await renderToolbarBoundary({ + aiPrompt: "Draft a kickoff", + setAiPrompt, + }); + const event = createTextareaKeydownEvent({ key: "Escape" }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(setAiPrompt).toHaveBeenCalledTimes(1); + expect(setAiPrompt).toHaveBeenCalledWith(""); + }); +}); diff --git a/tests/ai-toolbar-state.test.ts b/tests/ai-toolbar-state.test.ts new file mode 100644 index 0000000..32a62b2 --- /dev/null +++ b/tests/ai-toolbar-state.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { + getButtonOpeningTag, + registerAIToolbarTestHooks, + renderToolbarMarkup, +} from "./helpers/ai-toolbar-test-helpers"; + +registerAIToolbarTestHooks(); + +describe("AI toolbar state rendering", () => { + test("unauthenticated users see the sign-in-required callout", async () => { + const markup = await renderToolbarMarkup({ isAuthenticated: false }); + + expect(markup).toContain("Sign in required to generate event drafts with AI"); + expect(markup).toContain( + "Sign in to turn natural language or flyers into event drafts", + ); + }); + + test("admin-disabled state explains that AI is disabled by the administrator", async () => { + const markup = await renderToolbarMarkup({ + adminAiEnabled: false, + isAuthenticated: true, + }); + + expect(markup).toContain("AI integrations are unavailable"); + expect(markup).toContain( + "AI integrations are currently disabled by the administrator.", + ); + }); + + test("browser-disabled state explains that AI was turned off from settings", async () => { + const markup = await renderToolbarMarkup({ + adminAiEnabled: true, + aiEnabled: false, + isAuthenticated: true, + }); + + expect(markup).toContain("AI integrations are unavailable"); + expect(markup).toContain( + "AI has been turned off in this browser from Settings.", + ); + }); + + test("summary panel renders the summary body, updated timestamp, and dismiss affordance", async () => { + const markup = await renderToolbarMarkup({ + summary: "Three events need attention.", + summaryUpdated: "Updated just now", + }); + + expect(markup).toContain("AI Summary"); + expect(markup).toContain("Three events need attention."); + expect(markup).toContain("Updated just now"); + expect(markup).toContain("Dismiss AI summary"); + }); + + test("Summarize only appears when there are events to summarize", async () => { + const emptyMarkup = await renderToolbarMarkup({ events: [] }); + const populatedMarkup = await renderToolbarMarkup({ + events: [{ id: "event-1" }], + }); + + expect(emptyMarkup).not.toContain("Summarize"); + expect(populatedMarkup).toContain("Summarize"); + }); + + test("Generate event stays disabled while loading or when both prompt and images are absent", async () => { + const emptyMarkup = await renderToolbarMarkup(); + const loadingMarkup = await renderToolbarMarkup({ + aiLoading: true, + aiPrompt: "Draft a kickoff", + }); + const promptMarkup = await renderToolbarMarkup({ + aiPrompt: "Draft a kickoff", + }); + const imageMarkup = await renderToolbarMarkup({ + imagePreviews: ["blob:first"], + }); + + expect(getButtonOpeningTag(emptyMarkup, "Generate event")).toContain( + ' disabled=""', + ); + expect(getButtonOpeningTag(loadingMarkup, "Generating...")).toContain( + ' disabled=""', + ); + expect(getButtonOpeningTag(promptMarkup, "Generate event")).not.toContain( + " disabled=", + ); + expect(getButtonOpeningTag(imageMarkup, "Generate event")).not.toContain( + " disabled=", + ); + }); + + test("AI unavailable state removes the composer so generate shortcuts are not exposed", async () => { + const markup = await renderToolbarMarkup({ aiEnabled: false }); + + expect(markup).toContain("AI integrations are unavailable"); + expect(markup).not.toContain("Type or paste event details..."); + expect(markup).not.toContain("Generate event"); + }); +}); diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts deleted file mode 100644 index 68eba22..0000000 --- a/tests/ai-toolbar.test.ts +++ /dev/null @@ -1,1101 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import * as React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { imageFileKey } from "@/lib/multi-image"; - -type AIToolbarProps = { - adminAiEnabled: boolean; - aiEnabled: boolean; - isAuthenticated: boolean; - isPending: boolean; - aiPrompt: string; - setAiPrompt: (prompt: string) => void; - aiLoading: boolean; - imagePreviews: string[]; - onImagesSelect: (files: File[]) => void; - onImageRemove: (index: number) => void; - onAiCreate: () => void; - onAiTemplateSelect: (prompt: string) => void; - onAiSummarize: () => void; - onSummaryDismiss: () => void; - summary: string | null; - summaryUpdated: string | null; - events: Array<{ id?: string }>; -}; - -const createToolbarProps = ( - overrides: Partial = {}, -): AIToolbarProps => ({ - adminAiEnabled: true, - aiEnabled: true, - isAuthenticated: true, - isPending: false, - aiPrompt: "", - setAiPrompt: () => {}, - aiLoading: false, - imagePreviews: [], - onImagesSelect: () => {}, - onImageRemove: () => {}, - onAiCreate: () => {}, - onAiTemplateSelect: () => {}, - onAiSummarize: () => {}, - onSummaryDismiss: () => {}, - summary: null, - summaryUpdated: null, - events: [], - ...overrides, -}); - -const actualReact = React; -const documentAddEventListener = mock(); -const documentRemoveEventListener = mock(); -let lastEffectCleanup: (() => void) | undefined; -const registeredDocumentListeners = new Map< - string, - Array<{ listener: EventListener; options?: AddEventListenerOptions }> ->(); - -const getDocumentListener = ( - type: string, - predicate: (entry: { - listener: EventListener; - options?: AddEventListenerOptions; - }) => boolean = () => true, -) => { - const entry = registeredDocumentListeners.get(type)?.find(predicate); - expect(entry).toBeDefined(); - return entry!.listener; -}; - -const createClipboardData = (files: File[]): DataTransfer => - ({ - files, - items: files.map((file) => ({ - kind: "file", - type: file.type, - getAsFile: () => file, - })), - } as unknown as DataTransfer); - -beforeEach(() => { - documentAddEventListener.mockClear(); - documentRemoveEventListener.mockClear(); - lastEffectCleanup = undefined; - registeredDocumentListeners.clear(); - globalThis.document = { - addEventListener: documentAddEventListener, - removeEventListener: documentRemoveEventListener, - } as Document; - documentAddEventListener.mockImplementation( - ( - type: string, - listener: EventListener, - options?: AddEventListenerOptions, - ) => { - const listeners = registeredDocumentListeners.get(type) ?? []; - listeners.push({ listener, options }); - registeredDocumentListeners.set(type, listeners); - }, - ); - mock.module("react", () => ({ - ...actualReact, - useEffect: (effect: () => void | (() => void)) => { - const cleanup = effect(); - lastEffectCleanup = typeof cleanup === "function" ? cleanup : undefined; - }, - useRef: (initialValue: T) => ({ current: initialValue }), - useState: (initialValue: T) => [initialValue, mock()], - })); - mock.module("@/hooks/use-mobile", () => ({ - useIsMobile: () => false, - })); -}); - -afterEach(() => { - delete (globalThis as { document?: Document }).document; - delete (globalThis as { navigator?: Navigator }).navigator; - lastEffectCleanup = undefined; - mock.restore(); -}); - -const renderToolbar = async (overrides: Partial = {}) => { - const { AIToolbar } = await import("@/components/ai-toolbar"); - - AIToolbar(createToolbarProps(overrides)); -}; - -const renderToolbarMarkup = async ( - overrides: Partial = {}, - { isMobile = false }: { isMobile?: boolean } = {}, -) => { - mock.module("@/hooks/use-mobile", () => ({ - useIsMobile: () => isMobile, - })); - - const { AIToolbar } = await import("@/components/ai-toolbar"); - - return renderToStaticMarkup( - actualReact.createElement(AIToolbar, createToolbarProps(overrides)), - ); -}; - -const escapeForRegex = (value: string) => - value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -const getButtonOpeningTag = (markup: string, label: string) => { - const buttonMatch = markup.match( - new RegExp( - `]*>(?:(?!<\\/button>)[\\s\\S])*?${escapeForRegex(label)}(?:(?!<\\/button>)[\\s\\S])*?<\\/button>`, - ), - ); - const openingTagMatch = buttonMatch?.[0].match(/^]*>/); - - expect(openingTagMatch).toBeDefined(); - - return openingTagMatch![0]; -}; - -const getClassAttributes = (markup: string) => - [...markup.matchAll(/class="([^"]+)"/g)].map(([, className]) => className); - -const getMatchingClassTokens = ( - markup: string, - predicate: (tokens: string[]) => boolean, -) => - getClassAttributes(markup) - .map((className) => className.split(/\s+/).filter(Boolean)) - .filter(predicate); - -const createTextareaKeydownEvent = ( - overrides: Partial> = {}, -) => ({ - key: "Enter", - ctrlKey: false, - metaKey: false, - shiftKey: false, - altKey: false, - preventDefault: mock(), - ...overrides, -}) as unknown as React.KeyboardEvent; - -const createDocumentKeydownEvent = ( - overrides: Partial = {}, -) => ({ - key: "v", - ctrlKey: false, - metaKey: false, - shiftKey: false, - altKey: false, - target: { tagName: "DIV", isContentEditable: false }, - ...overrides, -}) as unknown as KeyboardEvent; - -const renderToolbarBoundary = async ( - overrides: Partial = {}, -) => { - let capturedTextareaProps: React.ComponentProps<"textarea"> | undefined; - const imageTriggerOpen = mock(); - - mock.module("@/components/ui/textarea", () => ({ - Textarea: (props: React.ComponentProps<"textarea">) => { - capturedTextareaProps = props; - return actualReact.createElement("textarea", props); - }, - })); - mock.module("@/components/image-picker", () => ({ - ImagePicker: ({ triggerRef, children, ...props }: React.ComponentProps<"button"> & { triggerRef?: { current: { open: () => void } | null } }) => { - if (triggerRef) { - triggerRef.current = { open: imageTriggerOpen }; - } - return actualReact.createElement("button", props, children); - }, - })); - - const { AIToolbar } = await import("@/components/ai-toolbar"); - - renderToStaticMarkup( - actualReact.createElement(AIToolbar, createToolbarProps(overrides)), - ); - - expect(capturedTextareaProps).toBeDefined(); - - return { textareaProps: capturedTextareaProps!, imageTriggerOpen }; -}; - -describe("AI toolbar layout contracts", () => { - test("desktop composer uses a dedicated multi-column branch while mobile stays single-column", async () => { - const desktopMarkup = await renderToolbarMarkup(); - const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true }); - const desktopLayoutTokens = getMatchingClassTokens( - desktopMarkup, - (tokens) => - tokens.includes("grid") && - tokens.some((token) => token.startsWith("grid-cols-[minmax(0,0.7fr)")), - ); - const mobileLayoutTokens = getMatchingClassTokens( - mobileMarkup, - (tokens) => tokens.includes("grid") && tokens.includes("gap-3"), - ); - - expect(desktopLayoutTokens).toHaveLength(1); - expect(desktopMarkup).toContain("Keyboard shortcuts"); - expect(desktopMarkup).toContain("Attachments"); - expect(mobileLayoutTokens).toHaveLength(1); - expect(mobileMarkup).not.toContain("Keyboard shortcuts"); - expect(mobileLayoutTokens[0].some((token) => token.startsWith("grid-cols-"))).toBe( - false, - ); - }); - - test("example prompts render as a masonry-style cluster below the textarea", async () => { - const markup = await renderToolbarMarkup(); - const masonryColumns = getMatchingClassTokens( - markup, - (tokens) => tokens.some((token) => token.startsWith("columns-")), - ); - const masonryWrappers = getMatchingClassTokens( - markup, - (tokens) => tokens.includes("break-inside-avoid"), - ); - const promptButtons = getMatchingClassTokens( - markup, - (tokens) => - tokens.includes("justify-start") && - tokens.includes("text-left") && - tokens.includes("whitespace-normal"), - ); - - expect(markup).toContain("Try:"); - expect(markup).toContain( - "Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.", - ); - expect(markup).toContain( - "Project sync tomorrow from 9am to 10am on Google Meet with a weekly repeat.", - ); - expect(markup).toContain( - "Dentist appointment on May 14 at 3pm at Smile Studio, add confirmation #A4821.", - ); - expect(masonryColumns).toHaveLength(1); - expect(masonryColumns[0]).toEqual( - expect.arrayContaining(["columns-2", "gap-2"]), - ); - expect(masonryWrappers).toHaveLength(3); - expect(promptButtons).toHaveLength(3); - }); - - test("attachments render as a separate surfaced panel with count badge, picker, and empty state", async () => { - const markup = await renderToolbarMarkup(); - - expect(markup).toContain("Attachments"); - expect(markup).toContain("0 files"); - expect(markup).toContain("Attach images"); - expect(markup).toContain( - "Drop or paste images here to pair them with the prompt.", - ); - }); - - test("attachment previews render in a stacked grid instead of a multi-column strip", async () => { - const markup = await renderToolbarMarkup({ - imagePreviews: ["blob:first", "blob:second"], - }); - const previewGridTokens = getMatchingClassTokens( - markup, - (tokens) => tokens.includes("grid") && tokens.includes("gap-2"), - ); - const multiColumnPreviewPattern = - /(?:^|\s)(?:[a-z]+:)*(?:grid-cols-(?:\[[^\]]+\]|\S+)|grid-flow-col|auto-cols-(?:\[[^\]]+\]|\S+)|columns-(?:\[[^\]]+\]|\S+)|overflow-x-auto|flex-row)/; - - expect(markup).toContain("Attached image 1"); - expect(markup).toContain("Attached image 2"); - expect(previewGridTokens.some((tokens) => !tokens.join(" ").match(multiColumnPreviewPattern))).toBe(true); - }); -}); - -describe("AI toolbar state rendering", () => { - test("unauthenticated users see the sign-in-required callout", async () => { - const markup = await renderToolbarMarkup({ isAuthenticated: false }); - - expect(markup).toContain("Sign in required to generate event drafts with AI"); - expect(markup).toContain( - "Sign in to turn natural language or flyers into event drafts", - ); - }); - - test("admin-disabled state explains that AI is disabled by the administrator", async () => { - const markup = await renderToolbarMarkup({ - adminAiEnabled: false, - isAuthenticated: true, - }); - - expect(markup).toContain("AI integrations are unavailable"); - expect(markup).toContain( - "AI integrations are currently disabled by the administrator.", - ); - }); - - test("browser-disabled state explains that AI was turned off from settings", async () => { - const markup = await renderToolbarMarkup({ - adminAiEnabled: true, - aiEnabled: false, - isAuthenticated: true, - }); - - expect(markup).toContain("AI integrations are unavailable"); - expect(markup).toContain( - "AI has been turned off in this browser from Settings.", - ); - }); - - test("summary panel renders the summary body, updated timestamp, and dismiss affordance", async () => { - const markup = await renderToolbarMarkup({ - summary: "Three events need attention.", - summaryUpdated: "Updated just now", - }); - - expect(markup).toContain("AI Summary"); - expect(markup).toContain("Three events need attention."); - expect(markup).toContain("Updated just now"); - expect(markup).toContain("Dismiss AI summary"); - }); - - test("Summarize only appears when there are events to summarize", async () => { - const emptyMarkup = await renderToolbarMarkup({ events: [] }); - const populatedMarkup = await renderToolbarMarkup({ - events: [{ id: "event-1" }], - }); - - expect(emptyMarkup).not.toContain("Summarize"); - expect(populatedMarkup).toContain("Summarize"); - }); - - test("Generate event stays disabled while loading or when both prompt and images are absent", async () => { - const emptyMarkup = await renderToolbarMarkup(); - const loadingMarkup = await renderToolbarMarkup({ - aiLoading: true, - aiPrompt: "Draft a kickoff", - }); - const promptMarkup = await renderToolbarMarkup({ - aiPrompt: "Draft a kickoff", - }); - const imageMarkup = await renderToolbarMarkup({ - imagePreviews: ["blob:first"], - }); - - expect(getButtonOpeningTag(emptyMarkup, "Generate event")).toContain( - " disabled=\"\"", - ); - expect(getButtonOpeningTag(loadingMarkup, "Generating...")).toContain( - " disabled=\"\"", - ); - expect(getButtonOpeningTag(promptMarkup, "Generate event")).not.toContain( - " disabled=", - ); - expect(getButtonOpeningTag(imageMarkup, "Generate event")).not.toContain( - " disabled=", - ); - }); -}); - -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("Ctrl+Enter triggers AI generation when the prompt has content", async () => { - const onAiCreate = mock(); - - const { textareaProps } = await renderToolbarBoundary({ - aiPrompt: "Draft a kickoff", - onAiCreate, - }); - const event = createTextareaKeydownEvent({ ctrlKey: true }); - - textareaProps.onKeyDown?.(event); - - expect(event.preventDefault).toHaveBeenCalledTimes(1); - expect(onAiCreate).toHaveBeenCalledTimes(1); - }); - - test("Cmd+Enter triggers AI generation when images are attached", async () => { - const onAiCreate = mock(); - - const { textareaProps } = await renderToolbarBoundary({ - imagePreviews: ["blob:first"], - onAiCreate, - }); - const event = createTextareaKeydownEvent({ metaKey: true }); - - textareaProps.onKeyDown?.(event); - - expect(event.preventDefault).toHaveBeenCalledTimes(1); - expect(onAiCreate).toHaveBeenCalledTimes(1); - }); - - test("Mod+Enter ignores extra modifiers so Shift+Ctrl+Enter does not generate", async () => { - const onAiCreate = mock(); - - const { textareaProps } = await renderToolbarBoundary({ - aiPrompt: "Draft a kickoff", - onAiCreate, - }); - const event = createTextareaKeydownEvent({ - ctrlKey: true, - shiftKey: true, - }); - - textareaProps.onKeyDown?.(event); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(onAiCreate).not.toHaveBeenCalled(); - }); - - test("Mod+Enter ignores combined Ctrl+Meta modifiers", async () => { - const onAiCreate = mock(); - - const { textareaProps } = await renderToolbarBoundary({ - aiPrompt: "Draft a kickoff", - onAiCreate, - }); - const event = createTextareaKeydownEvent({ - ctrlKey: true, - metaKey: true, - }); - - textareaProps.onKeyDown?.(event); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(onAiCreate).not.toHaveBeenCalled(); - }); - - test("Mod+Enter ignores Alt-modified submissions", async () => { - const onAiCreate = mock(); - - const { textareaProps } = await renderToolbarBoundary({ - aiPrompt: "Draft a kickoff", - onAiCreate, - }); - const event = createTextareaKeydownEvent({ - ctrlKey: true, - altKey: true, - }); - - textareaProps.onKeyDown?.(event); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(onAiCreate).not.toHaveBeenCalled(); - }); - - test("Mod+Enter does not trigger AI generation while loading", async () => { - const onAiCreate = mock(); - - const { textareaProps } = await renderToolbarBoundary({ - aiPrompt: "Draft a kickoff", - aiLoading: true, - onAiCreate, - }); - const event = createTextareaKeydownEvent({ ctrlKey: true }); - - textareaProps.onKeyDown?.(event); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(onAiCreate).not.toHaveBeenCalled(); - }); - - test("AI unavailable state removes the composer so generate shortcuts are not exposed", async () => { - const markup = await renderToolbarMarkup({ aiEnabled: false }); - - expect(markup).toContain("AI integrations are unavailable"); - expect(markup).not.toContain("Type or paste event details..."); - expect(markup).not.toContain("Generate event"); - }); - - test("Shift+Mod+A opens the image picker when the composer is idle", async () => { - const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); - const ctrlEvent = createTextareaKeydownEvent({ - key: "A", - ctrlKey: true, - shiftKey: true, - }); - - textareaProps.onKeyDown?.(ctrlEvent); - - expect(ctrlEvent.preventDefault).toHaveBeenCalledTimes(1); - expect(imageTriggerOpen).toHaveBeenCalledTimes(1); - }); - - test("Mod+A without Shift does not open the image picker", async () => { - const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); - const event = createTextareaKeydownEvent({ - key: "A", - ctrlKey: true, - }); - - textareaProps.onKeyDown?.(event); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(imageTriggerOpen).not.toHaveBeenCalled(); - }); - - test("Shift+Cmd+A also opens the image picker when the composer is idle", async () => { - const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); - const metaEvent = createTextareaKeydownEvent({ - key: "A", - metaKey: true, - shiftKey: true, - }); - - textareaProps.onKeyDown?.(metaEvent); - - expect(metaEvent.preventDefault).toHaveBeenCalledTimes(1); - expect(imageTriggerOpen).toHaveBeenCalledTimes(1); - }); - - test("Shift+Mod+A ignores Alt-modified submissions", async () => { - const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); - const event = createTextareaKeydownEvent({ - key: "A", - ctrlKey: true, - shiftKey: true, - altKey: true, - }); - - textareaProps.onKeyDown?.(event); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(imageTriggerOpen).not.toHaveBeenCalled(); - }); - - test("Shift+Mod+A ignores combined Ctrl+Meta modifiers", async () => { - const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); - const event = createTextareaKeydownEvent({ - key: "A", - ctrlKey: true, - metaKey: true, - shiftKey: true, - }); - - textareaProps.onKeyDown?.(event); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(imageTriggerOpen).not.toHaveBeenCalled(); - }); - - test("Shift+Mod+A stays disabled while generation is in progress", async () => { - const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary({ - aiLoading: true, - }); - const ctrlEvent = createTextareaKeydownEvent({ - key: "A", - ctrlKey: true, - shiftKey: true, - }); - - textareaProps.onKeyDown?.(ctrlEvent); - - expect(ctrlEvent.preventDefault).not.toHaveBeenCalled(); - expect(imageTriggerOpen).not.toHaveBeenCalled(); - }); - - test("Escape clears the prompt when the textarea has content", async () => { - const setAiPrompt = mock(); - - const { textareaProps } = await renderToolbarBoundary({ - aiPrompt: "Draft a kickoff", - setAiPrompt, - }); - const event = createTextareaKeydownEvent({ key: "Escape" }); - - textareaProps.onKeyDown?.(event); - - expect(event.preventDefault).toHaveBeenCalledTimes(1); - expect(setAiPrompt).toHaveBeenCalledTimes(1); - expect(setAiPrompt).toHaveBeenCalledWith(""); - }); - - 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("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)); - - registeredDocumentListeners.clear(); - documentAddEventListener.mockClear(); - documentRemoveEventListener.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(); - - expect(documentAddEventListener).toHaveBeenCalledTimes(3); - expect(documentAddEventListener).toHaveBeenCalledWith( - "paste", - expect.any(Function), - ); - expect(documentAddEventListener).toHaveBeenCalledWith( - "paste", - expect.any(Function), - { capture: true }, - ); - expect(documentAddEventListener).toHaveBeenCalledWith( - "keydown", - expect.any(Function), - ); - - lastEffectCleanup?.(); - - expect(documentRemoveEventListener).toHaveBeenCalledTimes(3); - expect(documentRemoveEventListener).toHaveBeenCalledWith( - "paste", - expect.any(Function), - ); - expect(documentRemoveEventListener).toHaveBeenCalledWith( - "paste", - expect.any(Function), - { capture: true }, - ); - expect(documentRemoveEventListener).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 }); - - expect(documentAddEventListener).not.toHaveBeenCalled(); - expect(documentRemoveEventListener).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/helpers/ai-toolbar-test-helpers.ts b/tests/helpers/ai-toolbar-test-helpers.ts new file mode 100644 index 0000000..fa68be2 --- /dev/null +++ b/tests/helpers/ai-toolbar-test-helpers.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, expect, mock } from "bun:test"; +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +export type AIToolbarProps = { + adminAiEnabled: boolean; + aiEnabled: boolean; + isAuthenticated: boolean; + isPending: boolean; + aiPrompt: string; + setAiPrompt: (prompt: string) => void; + aiLoading: boolean; + imagePreviews: string[]; + onImagesSelect: (files: File[]) => void; + onImageRemove: (index: number) => void; + onAiCreate: () => void; + onAiTemplateSelect: (prompt: string) => void; + onAiSummarize: () => void; + onSummaryDismiss: () => void; + summary: string | null; + summaryUpdated: string | null; + events: Array<{ id?: string }>; +}; + +const actualReact = React; +const documentAddEventListener = mock(); +const documentRemoveEventListener = mock(); +let lastEffectCleanup: (() => void) | undefined; +const registeredDocumentListeners = new Map< + string, + Array<{ listener: EventListener; options?: AddEventListenerOptions }> +>(); + +export const createToolbarProps = ( + overrides: Partial = {}, +): AIToolbarProps => ({ + adminAiEnabled: true, + aiEnabled: true, + isAuthenticated: true, + isPending: false, + aiPrompt: "", + setAiPrompt: () => {}, + aiLoading: false, + imagePreviews: [], + onImagesSelect: () => {}, + onImageRemove: () => {}, + onAiCreate: () => {}, + onAiTemplateSelect: () => {}, + onAiSummarize: () => {}, + onSummaryDismiss: () => {}, + summary: null, + summaryUpdated: null, + events: [], + ...overrides, +}); + +export const registerAIToolbarTestHooks = () => { + beforeEach(() => { + documentAddEventListener.mockClear(); + documentRemoveEventListener.mockClear(); + lastEffectCleanup = undefined; + registeredDocumentListeners.clear(); + globalThis.document = { + addEventListener: documentAddEventListener, + removeEventListener: documentRemoveEventListener, + } as Document; + documentAddEventListener.mockImplementation( + ( + type: string, + listener: EventListener, + options?: AddEventListenerOptions, + ) => { + const listeners = registeredDocumentListeners.get(type) ?? []; + listeners.push({ listener, options }); + registeredDocumentListeners.set(type, listeners); + }, + ); + mock.module("react", () => ({ + ...actualReact, + useEffect: (effect: () => void | (() => void)) => { + const cleanup = effect(); + lastEffectCleanup = typeof cleanup === "function" ? cleanup : undefined; + }, + useRef: (initialValue: T) => ({ current: initialValue }), + useState: (initialValue: T) => [initialValue, mock()], + })); + mock.module("@/hooks/use-mobile", () => ({ + useIsMobile: () => false, + })); + }); + + afterEach(() => { + delete (globalThis as { document?: Document }).document; + delete (globalThis as { navigator?: Navigator }).navigator; + lastEffectCleanup = undefined; + mock.restore(); + }); +}; + +export const renderToolbar = async ( + overrides: Partial = {}, +) => { + const { AIToolbar } = await import("@/components/ai-toolbar"); + + AIToolbar(createToolbarProps(overrides)); +}; + +export const renderToolbarMarkup = async ( + overrides: Partial = {}, + { isMobile = false }: { isMobile?: boolean } = {}, +) => { + mock.module("@/hooks/use-mobile", () => ({ + useIsMobile: () => isMobile, + })); + + const { AIToolbar } = await import("@/components/ai-toolbar"); + + return renderToStaticMarkup( + actualReact.createElement(AIToolbar, createToolbarProps(overrides)), + ); +}; + +export const renderToolbarBoundary = async ( + overrides: Partial = {}, +) => { + let capturedTextareaProps: React.ComponentProps<"textarea"> | undefined; + const imageTriggerOpen = mock(); + + mock.module("@/components/ui/textarea", () => ({ + Textarea: (props: React.ComponentProps<"textarea">) => { + capturedTextareaProps = props; + return actualReact.createElement("textarea", props); + }, + })); + mock.module("@/components/image-picker", () => ({ + ImagePicker: ({ + triggerRef, + children, + ...props + }: React.ComponentProps<"button"> & { + triggerRef?: { current: { open: () => void } | null }; + }) => { + if (triggerRef) { + triggerRef.current = { open: imageTriggerOpen }; + } + return actualReact.createElement("button", props, children); + }, + })); + + const { AIToolbar } = await import("@/components/ai-toolbar"); + + renderToStaticMarkup( + actualReact.createElement(AIToolbar, createToolbarProps(overrides)), + ); + + expect(capturedTextareaProps).toBeDefined(); + + return { textareaProps: capturedTextareaProps!, imageTriggerOpen }; +}; + +export const getDocumentListener = ( + type: string, + predicate: (entry: { + listener: EventListener; + options?: AddEventListenerOptions; + }) => boolean = () => true, +) => { + const entry = registeredDocumentListeners.get(type)?.find(predicate); + expect(entry).toBeDefined(); + return entry!.listener; +}; + +export const getDocumentListenerMocks = () => ({ + addEventListener: documentAddEventListener, + removeEventListener: documentRemoveEventListener, + lastEffectCleanup, +}); + +export const resetRegisteredDocumentListeners = () => { + registeredDocumentListeners.clear(); +}; + +export const createClipboardData = (files: File[]): DataTransfer => + ({ + files, + items: files.map((file) => ({ + kind: "file", + type: file.type, + getAsFile: () => file, + })), + } as unknown as DataTransfer); + +export const createTextareaKeydownEvent = ( + overrides: Partial> = {}, +) => ({ + key: "Enter", + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + preventDefault: mock(), + ...overrides, +}) as unknown as React.KeyboardEvent; + +export const createDocumentKeydownEvent = ( + overrides: Partial = {}, +) => ({ + key: "v", + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + target: { tagName: "DIV", isContentEditable: false }, + ...overrides, +}) as unknown as KeyboardEvent; + +const escapeForRegex = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +export const getButtonOpeningTag = (markup: string, label: string) => { + const buttonMatch = markup.match( + new RegExp( + `]*>(?:(?!<\\/button>)[\\s\\S])*?${escapeForRegex(label)}(?:(?!<\\/button>)[\\s\\S])*?<\\/button>`, + ), + ); + const openingTagMatch = buttonMatch?.[0].match(/^]*>/); + + expect(openingTagMatch).toBeDefined(); + + return openingTagMatch![0]; +}; + +const getClassAttributes = (markup: string) => + [...markup.matchAll(/class="([^"]+)"/g)].map(([, className]) => className); + +export const getMatchingClassTokens = ( + markup: string, + predicate: (tokens: string[]) => boolean, +) => + getClassAttributes(markup) + .map((className) => className.split(/\s+/).filter(Boolean)) + .filter(predicate);