test(ai-toolbar): cover shortcut and dedup behavior

This commit is contained in:
2026-04-22 23:54:01 -04:00
parent f2816247c1
commit 1d2bbdf46c

View File

@@ -177,10 +177,23 @@ const createTextareaKeydownEvent = (
...overrides, ...overrides,
}) as unknown as React.KeyboardEvent<HTMLTextAreaElement>; }) as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
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 renderToolbarBoundary = async ( const renderToolbarBoundary = async (
overrides: Partial<AIToolbarProps> = {}, overrides: Partial<AIToolbarProps> = {},
) => { ) => {
let capturedTextareaProps: React.ComponentProps<"textarea"> | undefined; let capturedTextareaProps: React.ComponentProps<"textarea"> | undefined;
const imageTriggerOpen = mock();
mock.module("@/components/ui/textarea", () => ({ mock.module("@/components/ui/textarea", () => ({
Textarea: (props: React.ComponentProps<"textarea">) => { Textarea: (props: React.ComponentProps<"textarea">) => {
@@ -188,6 +201,14 @@ const renderToolbarBoundary = async (
return actualReact.createElement("textarea", 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"); const { AIToolbar } = await import("@/components/ai-toolbar");
@@ -197,16 +218,18 @@ const renderToolbarBoundary = async (
expect(capturedTextareaProps).toBeDefined(); expect(capturedTextareaProps).toBeDefined();
return capturedTextareaProps!; return { textareaProps: capturedTextareaProps!, imageTriggerOpen };
}; };
describe("AI toolbar layout contracts", () => { describe("AI toolbar layout contracts", () => {
test("desktop composer renders a 70/30 split while mobile stays single-column", async () => { test("desktop composer uses a dedicated multi-column branch while mobile stays single-column", async () => {
const desktopMarkup = await renderToolbarMarkup(); const desktopMarkup = await renderToolbarMarkup();
const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true }); const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true });
const desktopLayoutTokens = getMatchingClassTokens( const desktopLayoutTokens = getMatchingClassTokens(
desktopMarkup, desktopMarkup,
(tokens) => tokens.includes("grid") && tokens.includes("gap-3"), (tokens) =>
tokens.includes("grid") &&
tokens.some((token) => token.startsWith("grid-cols-[minmax(0,0.7fr)")),
); );
const mobileLayoutTokens = getMatchingClassTokens( const mobileLayoutTokens = getMatchingClassTokens(
mobileMarkup, mobileMarkup,
@@ -214,20 +237,10 @@ describe("AI toolbar layout contracts", () => {
); );
expect(desktopLayoutTokens).toHaveLength(1); expect(desktopLayoutTokens).toHaveLength(1);
expect(desktopLayoutTokens[0]).toEqual( expect(desktopMarkup).toContain("Keyboard shortcuts");
expect.arrayContaining([ expect(desktopMarkup).toContain("Attachments");
"grid",
"gap-3",
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
]),
);
expect(mobileLayoutTokens).toHaveLength(1); expect(mobileLayoutTokens).toHaveLength(1);
expect(mobileLayoutTokens[0]).toEqual( expect(mobileMarkup).not.toContain("Keyboard shortcuts");
expect.arrayContaining(["grid", "gap-3"]),
);
expect(mobileLayoutTokens[0]).not.toContain(
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
);
expect(mobileLayoutTokens[0].some((token) => token.startsWith("grid-cols-"))).toBe( expect(mobileLayoutTokens[0].some((token) => token.startsWith("grid-cols-"))).toBe(
false, false,
); );
@@ -237,19 +250,18 @@ describe("AI toolbar layout contracts", () => {
const markup = await renderToolbarMarkup(); const markup = await renderToolbarMarkup();
const masonryColumns = getMatchingClassTokens( const masonryColumns = getMatchingClassTokens(
markup, markup,
(tokens) => tokens.includes("columns-2"), (tokens) => tokens.some((token) => token.startsWith("columns-")),
); );
const masonryWrappers = getMatchingClassTokens( const masonryWrappers = getMatchingClassTokens(
markup, markup,
(tokens) => (tokens) => tokens.includes("break-inside-avoid"),
tokens.includes("mb-2") && tokens.includes("break-inside-avoid"),
); );
const promptButtons = getMatchingClassTokens( const promptButtons = getMatchingClassTokens(
markup, markup,
(tokens) => (tokens) =>
tokens.includes("justify-start") && tokens.includes("justify-start") &&
tokens.includes("whitespace-normal") && tokens.includes("text-left") &&
tokens.includes("rounded-2xl"), tokens.includes("whitespace-normal"),
); );
expect(markup).toContain("Try:"); expect(markup).toContain("Try:");
@@ -287,21 +299,14 @@ describe("AI toolbar layout contracts", () => {
}); });
const previewGridTokens = getMatchingClassTokens( const previewGridTokens = getMatchingClassTokens(
markup, markup,
(tokens) => (tokens) => tokens.includes("grid") && tokens.includes("gap-2"),
tokens.includes("mt-3") &&
tokens.includes("grid") &&
tokens.includes("gap-2"),
); );
const multiColumnPreviewPattern = const multiColumnPreviewPattern =
/(?:^|\s)(?:[a-z]+:)*(?:grid-cols-(?:\[[^\]]+\]|\S+)|grid-flow-col|auto-cols-(?:\[[^\]]+\]|\S+)|columns-(?:\[[^\]]+\]|\S+)|overflow-x-auto|flex-row)/; /(?:^|\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 1");
expect(markup).toContain("Attached image 2"); expect(markup).toContain("Attached image 2");
expect(previewGridTokens).toHaveLength(1); expect(previewGridTokens.some((tokens) => !tokens.join(" ").match(multiColumnPreviewPattern))).toBe(true);
expect(previewGridTokens[0]).toEqual(
expect.arrayContaining(["mt-3", "grid", "gap-2"]),
);
expect(previewGridTokens[0].join(" ")).not.toMatch(multiColumnPreviewPattern);
}); });
}); });
@@ -397,7 +402,7 @@ describe("AI toolbar paste capture", () => {
type: "image/png", type: "image/png",
}); });
const textareaProps = await renderToolbarBoundary({ onImagesSelect }); const { textareaProps } = await renderToolbarBoundary({ onImagesSelect });
const preventDefault = mock(); const preventDefault = mock();
textareaProps.onPaste?.({ textareaProps.onPaste?.({
@@ -416,7 +421,7 @@ describe("AI toolbar paste capture", () => {
type: "image/png", type: "image/png",
}); });
const textareaProps = await renderToolbarBoundary({ onImagesSelect }); const { textareaProps } = await renderToolbarBoundary({ onImagesSelect });
const handleCapturePaste = getDocumentListener( const handleCapturePaste = getDocumentListener(
"paste", "paste",
(entry) => entry.options?.capture === true, (entry) => entry.options?.capture === true,
@@ -456,7 +461,7 @@ describe("AI toolbar paste capture", () => {
test("Ctrl+Enter triggers AI generation when the prompt has content", async () => { test("Ctrl+Enter triggers AI generation when the prompt has content", async () => {
const onAiCreate = mock(); const onAiCreate = mock();
const textareaProps = await renderToolbarBoundary({ const { textareaProps } = await renderToolbarBoundary({
aiPrompt: "Draft a kickoff", aiPrompt: "Draft a kickoff",
onAiCreate, onAiCreate,
}); });
@@ -471,7 +476,7 @@ describe("AI toolbar paste capture", () => {
test("Cmd+Enter triggers AI generation when images are attached", async () => { test("Cmd+Enter triggers AI generation when images are attached", async () => {
const onAiCreate = mock(); const onAiCreate = mock();
const textareaProps = await renderToolbarBoundary({ const { textareaProps } = await renderToolbarBoundary({
imagePreviews: ["blob:first"], imagePreviews: ["blob:first"],
onAiCreate, onAiCreate,
}); });
@@ -483,10 +488,40 @@ describe("AI toolbar paste capture", () => {
expect(onAiCreate).toHaveBeenCalledTimes(1); expect(onAiCreate).toHaveBeenCalledTimes(1);
}); });
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("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 () => { test("Escape clears the prompt when the textarea has content", async () => {
const setAiPrompt = mock(); const setAiPrompt = mock();
const textareaProps = await renderToolbarBoundary({ const { textareaProps } = await renderToolbarBoundary({
aiPrompt: "Draft a kickoff", aiPrompt: "Draft a kickoff",
setAiPrompt, setAiPrompt,
}); });
@@ -557,6 +592,45 @@ describe("AI toolbar paste capture", () => {
expect(onImagesSelect.mock.calls[0]?.[0][0]?.type).toBe("image/png"); expect(onImagesSelect.mock.calls[0]?.[0][0]?.type).toBe("image/png");
}); });
test("async clipboard fallback does not double-handle a paste already captured by the synchronous paste event", 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 handleDocumentKeydown = getDocumentListener("keydown");
const image = new File(["image-bytes"], "clipboard.png", {
type: "image/png",
});
const keydownPromise = handleDocumentKeydown(
createDocumentKeydownEvent({ ctrlKey: true }),
);
handleCapturePaste({
target: { tagName: "DIV", isContentEditable: false },
clipboardData: createClipboardData([image]),
} as unknown as Event);
await keydownPromise;
expect(clipboardRead).not.toHaveBeenCalled();
expect(onImagesSelect).not.toHaveBeenCalled();
});
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 () => {
await renderToolbar(); await renderToolbar();