test(ai-toolbar): cover normalized clipboard paste paths
This commit is contained in:
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user