From 85f4066ce2de6f48de2d2c9340703a2579dfcbfa Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Thu, 23 Apr 2026 05:04:00 -0400 Subject: [PATCH] test(ai-toolbar): cover normalized clipboard paste paths --- src/components/ai-toolbar.tsx | 21 ++++++--- tests/ai-toolbar.test.ts | 80 ++++++++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/components/ai-toolbar.tsx b/src/components/ai-toolbar.tsx index 9ec0f06..942aa64 100644 --- a/src/components/ai-toolbar.tsx +++ b/src/components/ai-toolbar.tsx @@ -52,15 +52,18 @@ async function getClipboardBlobIdentity(blob: Blob): Promise { return (hash >>> 0).toString(16).padStart(8, "0"); } -async function createClipboardFallbackFile(blob: Blob): Promise { +async function normalizeClipboardImageFile(blob: Blob): Promise { const identity = await getClipboardBlobIdentity(blob); - const extension = blob.type.split("/")[1] || "png"; - return new File([blob], `clipboard-image-${identity}.${extension}`, { + return new File([blob], `clipboard-image-${identity}`, { type: blob.type, lastModified: 0, }); } +async function normalizeClipboardImageFiles(files: File[]): Promise { + return Promise.all(files.map((file) => normalizeClipboardImageFile(file))); +} + // ─── Shared shortcuts list (rendered in both HoverCard and Popover) ─────────── function ShortcutsList({ os }: { os: Os }) { @@ -194,7 +197,9 @@ export const AIToolbar = ({ const images = extractAllImagesFromClipboard(e.clipboardData ?? null); if (images.length > 0) { e.preventDefault(); - onImagesSelectRef.current(images); + void normalizeClipboardImageFiles(images).then((normalizedImages) => { + onImagesSelectRef.current(normalizedImages); + }); } }; @@ -241,7 +246,7 @@ export const AIToolbar = ({ for (const mimeType of typesToTry) { try { const blob = await clipboardItem.getType(mimeType); - files.push(await createClipboardFallbackFile(blob)); + files.push(await normalizeClipboardImageFile(blob)); break; // got this item, move to next clipboardItem } catch { // NotFoundError — type not present, try next @@ -359,7 +364,11 @@ export const AIToolbar = ({ ); if (images.length > 0) { e.preventDefault(); - onImagesSelect(images); + void normalizeClipboardImageFiles(images).then( + (normalizedImages) => { + onImagesSelect(normalizedImages); + }, + ); } }} /> diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index 567555c..b9516f3 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -405,14 +405,17 @@ describe("AI toolbar paste capture", () => { const { textareaProps } = await renderToolbarBoundary({ onImagesSelect }); const preventDefault = mock(); - textareaProps.onPaste?.({ + 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).toHaveBeenCalledWith([image]); + 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 () => { @@ -446,12 +449,15 @@ describe("AI toolbar paste capture", () => { expect(preventDefault).not.toHaveBeenCalled(); expect(onImagesSelect).not.toHaveBeenCalled(); - textareaProps.onPaste?.( + 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).toHaveBeenCalledWith([image]); + expect(onImagesSelect.mock.calls[0]?.[0][0]?.name).toMatch( + /^clipboard-image-/, + ); handleDocumentPaste(pasteEvent as unknown as Event); expect(preventDefault).toHaveBeenCalledTimes(1); @@ -524,6 +530,24 @@ describe("AI toolbar paste capture", () => { 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(); @@ -627,10 +651,13 @@ describe("AI toolbar paste capture", () => { 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).toHaveBeenCalledWith([image]); + expect(onImagesSelect.mock.calls[0]?.[0][0]?.name).toMatch( + /^clipboard-image-/, + ); }); test("Ctrl/Cmd+V fallback forwards clipboard images for non-editable targets", async () => { @@ -731,6 +758,45 @@ describe("AI toolbar paste capture", () => { ); }); + 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("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 () => [ @@ -778,7 +844,9 @@ describe("AI toolbar paste capture", () => { expect(preventDefault).toHaveBeenCalledTimes(1); expect(clipboardRead).not.toHaveBeenCalled(); expect(onImagesSelect).toHaveBeenCalledTimes(1); - expect(onImagesSelect).toHaveBeenCalledWith([image]); + 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 () => {