test(ai-toolbar): cover normalized clipboard paste paths

This commit is contained in:
2026-04-23 05:04:00 -04:00
parent 35731fb684
commit 85f4066ce2
2 changed files with 89 additions and 12 deletions

View File

@@ -52,15 +52,18 @@ async function getClipboardBlobIdentity(blob: Blob): Promise<string> {
return (hash >>> 0).toString(16).padStart(8, "0"); return (hash >>> 0).toString(16).padStart(8, "0");
} }
async function createClipboardFallbackFile(blob: Blob): Promise<File> { async function normalizeClipboardImageFile(blob: Blob): Promise<File> {
const identity = await getClipboardBlobIdentity(blob); const identity = await getClipboardBlobIdentity(blob);
const extension = blob.type.split("/")[1] || "png"; return new File([blob], `clipboard-image-${identity}`, {
return new File([blob], `clipboard-image-${identity}.${extension}`, {
type: blob.type, type: blob.type,
lastModified: 0, lastModified: 0,
}); });
} }
async function normalizeClipboardImageFiles(files: File[]): Promise<File[]> {
return Promise.all(files.map((file) => normalizeClipboardImageFile(file)));
}
// ─── Shared shortcuts list (rendered in both HoverCard and Popover) ─────────── // ─── Shared shortcuts list (rendered in both HoverCard and Popover) ───────────
function ShortcutsList({ os }: { os: Os }) { function ShortcutsList({ os }: { os: Os }) {
@@ -194,7 +197,9 @@ export const AIToolbar = ({
const images = extractAllImagesFromClipboard(e.clipboardData ?? null); const images = extractAllImagesFromClipboard(e.clipboardData ?? null);
if (images.length > 0) { if (images.length > 0) {
e.preventDefault(); 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) { for (const mimeType of typesToTry) {
try { try {
const blob = await clipboardItem.getType(mimeType); const blob = await clipboardItem.getType(mimeType);
files.push(await createClipboardFallbackFile(blob)); files.push(await normalizeClipboardImageFile(blob));
break; // got this item, move to next clipboardItem break; // got this item, move to next clipboardItem
} catch { } catch {
// NotFoundError — type not present, try next // NotFoundError — type not present, try next
@@ -359,7 +364,11 @@ export const AIToolbar = ({
); );
if (images.length > 0) { if (images.length > 0) {
e.preventDefault(); e.preventDefault();
onImagesSelect(images); void normalizeClipboardImageFiles(images).then(
(normalizedImages) => {
onImagesSelect(normalizedImages);
},
);
} }
}} }}
/> />

View File

@@ -405,14 +405,17 @@ describe("AI toolbar paste capture", () => {
const { textareaProps } = await renderToolbarBoundary({ onImagesSelect }); const { textareaProps } = await renderToolbarBoundary({ onImagesSelect });
const preventDefault = mock(); const preventDefault = mock();
textareaProps.onPaste?.({ await textareaProps.onPaste?.({
clipboardData: createClipboardData([image]), clipboardData: createClipboardData([image]),
preventDefault, preventDefault,
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>); } as unknown as React.ClipboardEvent<HTMLTextAreaElement>);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
expect(onImagesSelect).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 () => { 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(preventDefault).not.toHaveBeenCalled();
expect(onImagesSelect).not.toHaveBeenCalled(); expect(onImagesSelect).not.toHaveBeenCalled();
textareaProps.onPaste?.( await textareaProps.onPaste?.(
pasteEvent as unknown as React.ClipboardEvent<HTMLTextAreaElement>, pasteEvent as unknown as React.ClipboardEvent<HTMLTextAreaElement>,
); );
await new Promise((resolve) => setTimeout(resolve, 0));
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
expect(onImagesSelect).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); handleDocumentPaste(pasteEvent as unknown as Event);
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
@@ -524,6 +530,24 @@ describe("AI toolbar paste capture", () => {
expect(onAiCreate).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 () => { test("Mod+Enter does not trigger AI generation while loading", async () => {
const onAiCreate = mock(); const onAiCreate = mock();
@@ -627,10 +651,13 @@ describe("AI toolbar paste capture", () => {
clipboardData: createClipboardData([image]), clipboardData: createClipboardData([image]),
preventDefault, preventDefault,
} as unknown as Event); } as unknown as Event);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
expect(onImagesSelect).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 () => { 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 () => { test("async clipboard fallback does not double-handle a paste already handled by the synchronous document paste flow", async () => {
const onImagesSelect = mock(); const onImagesSelect = mock();
const clipboardRead = mock(async () => [ const clipboardRead = mock(async () => [
@@ -778,7 +844,9 @@ describe("AI toolbar paste capture", () => {
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
expect(clipboardRead).not.toHaveBeenCalled(); expect(clipboardRead).not.toHaveBeenCalled();
expect(onImagesSelect).toHaveBeenCalledTimes(1); 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 () => { test("global paste listeners only arm when AI paste capture is usable, and disarm on cleanup", async () => {