Files
local-cal/tests/ai-toolbar.test.ts

730 lines
22 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
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 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,
});
const actualReact = React;
const documentAddEventListener = mock();
const documentRemoveEventListener = mock();
let lastEffectCleanup: (() => void) | undefined;
const registeredDocumentListeners = new Map<
string,
Array<{ listener: EventListener; options?: AddEventListenerOptions }>
>();
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;
};
const createClipboardData = (files: File[]): DataTransfer =>
({
files,
items: files.map((file) => ({
kind: "file",
type: file.type,
getAsFile: () => file,
})),
} as unknown as DataTransfer);
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();
});
const renderToolbar = async (overrides: Partial<AIToolbarProps> = {}) => {
const { AIToolbar } = await import("@/components/ai-toolbar");
AIToolbar(createToolbarProps(overrides));
};
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)),
);
};
const escapeForRegex = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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);
const getMatchingClassTokens = (
markup: string,
predicate: (tokens: string[]) => boolean,
) =>
getClassAttributes(markup)
.map((className) => className.split(/\s+/).filter(Boolean))
.filter(predicate);
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>;
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 (
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 };
};
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);
});
});
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=",
);
});
});
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();
textareaProps.onPaste?.({
clipboardData: createClipboardData([image]),
preventDefault,
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>);
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(onImagesSelect).toHaveBeenCalledTimes(1);
expect(onImagesSelect).toHaveBeenCalledWith([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();
textareaProps.onPaste?.(
pasteEvent as unknown as React.ClipboardEvent<HTMLTextAreaElement>,
);
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(onImagesSelect).toHaveBeenCalledTimes(1);
expect(onImagesSelect).toHaveBeenCalledWith([image]);
handleDocumentPaste(pasteEvent as unknown as Event);
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(onImagesSelect).toHaveBeenCalledTimes(1);
});
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 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("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");
});
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+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 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("");
});
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);
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(onImagesSelect).toHaveBeenCalledTimes(1);
expect(onImagesSelect).toHaveBeenCalledWith([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("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).toHaveBeenCalledWith([image]);
});
test("global paste listeners only arm when AI paste capture is usable, and disarm on cleanup", async () => {
await renderToolbar();
expect(documentAddEventListener).toHaveBeenCalledTimes(3);
expect(documentAddEventListener).toHaveBeenCalledWith(
"paste",
expect.any(Function),
);
expect(documentAddEventListener).toHaveBeenCalledWith(
"paste",
expect.any(Function),
{ capture: true },
);
expect(documentAddEventListener).toHaveBeenCalledWith(
"keydown",
expect.any(Function),
);
lastEffectCleanup?.();
expect(documentRemoveEventListener).toHaveBeenCalledTimes(3);
expect(documentRemoveEventListener).toHaveBeenCalledWith(
"paste",
expect.any(Function),
);
expect(documentRemoveEventListener).toHaveBeenCalledWith(
"paste",
expect.any(Function),
{ capture: true },
);
expect(documentRemoveEventListener).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 });
expect(documentAddEventListener).not.toHaveBeenCalled();
expect(documentRemoveEventListener).not.toHaveBeenCalled();
});
});