test(ai-toolbar): split coverage and add fallback failure cases
This commit is contained in:
546
tests/ai-toolbar-paste.test.ts
Normal file
546
tests/ai-toolbar-paste.test.ts
Normal file
@@ -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<HTMLTextAreaElement>);
|
||||
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<HTMLTextAreaElement>,
|
||||
);
|
||||
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<HTMLTextAreaElement>);
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user