test(ai-toolbar): split coverage and add fallback failure cases
This commit is contained in:
101
tests/ai-toolbar-layout.test.ts
Normal file
101
tests/ai-toolbar-layout.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
213
tests/ai-toolbar-shortcuts.test.ts
Normal file
213
tests/ai-toolbar-shortcuts.test.ts
Normal file
@@ -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("");
|
||||
});
|
||||
});
|
||||
101
tests/ai-toolbar-state.test.ts
Normal file
101
tests/ai-toolbar-state.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
242
tests/helpers/ai-toolbar-test-helpers.ts
Normal file
242
tests/helpers/ai-toolbar-test-helpers.ts
Normal file
@@ -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> = {},
|
||||
): 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: <T,>(initialValue: T) => ({ current: initialValue }),
|
||||
useState: <T,>(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<AIToolbarProps> = {},
|
||||
) => {
|
||||
const { AIToolbar } = await import("@/components/ai-toolbar");
|
||||
|
||||
AIToolbar(createToolbarProps(overrides));
|
||||
};
|
||||
|
||||
export const renderToolbarMarkup = async (
|
||||
overrides: Partial<AIToolbarProps> = {},
|
||||
{ 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<AIToolbarProps> = {},
|
||||
) => {
|
||||
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<React.KeyboardEvent<HTMLTextAreaElement>> = {},
|
||||
) => ({
|
||||
key: "Enter",
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: mock(),
|
||||
...overrides,
|
||||
}) as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
|
||||
|
||||
export const createDocumentKeydownEvent = (
|
||||
overrides: Partial<KeyboardEvent> = {},
|
||||
) => ({
|
||||
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\\b[^>]*>(?:(?!<\\/button>)[\\s\\S])*?${escapeForRegex(label)}(?:(?!<\\/button>)[\\s\\S])*?<\\/button>`,
|
||||
),
|
||||
);
|
||||
const openingTagMatch = buttonMatch?.[0].match(/^<button\b[^>]*>/);
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user