489 lines
15 KiB
TypeScript
489 lines
15 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 renderToolbarBoundary = async (
|
|
overrides: Partial<AIToolbarProps> = {},
|
|
) => {
|
|
let capturedTextareaProps: React.ComponentProps<"textarea"> | undefined;
|
|
|
|
mock.module("@/components/ui/textarea", () => ({
|
|
Textarea: (props: React.ComponentProps<"textarea">) => {
|
|
capturedTextareaProps = props;
|
|
return actualReact.createElement("textarea", props);
|
|
},
|
|
}));
|
|
|
|
const { AIToolbar } = await import("@/components/ai-toolbar");
|
|
|
|
renderToStaticMarkup(
|
|
actualReact.createElement(AIToolbar, createToolbarProps(overrides)),
|
|
);
|
|
|
|
expect(capturedTextareaProps).toBeDefined();
|
|
|
|
return capturedTextareaProps!;
|
|
};
|
|
|
|
describe("AI toolbar layout contracts", () => {
|
|
test("desktop composer renders a 70/30 split while mobile stays single-column", async () => {
|
|
const desktopMarkup = await renderToolbarMarkup();
|
|
const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true });
|
|
|
|
expect(desktopMarkup).toContain(
|
|
"grid gap-3 grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
|
|
);
|
|
expect(desktopMarkup).not.toContain(
|
|
"grid gap-3 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]",
|
|
);
|
|
expect(mobileMarkup).not.toContain(
|
|
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
|
|
);
|
|
});
|
|
|
|
test("example prompts render as a masonry-style cluster below the textarea", async () => {
|
|
const markup = await renderToolbarMarkup();
|
|
|
|
expect(markup).toContain("Try:");
|
|
expect(markup).toContain('class="columns-2 gap-2"');
|
|
expect(markup).toContain('class="mb-2 break-inside-avoid"');
|
|
expect(markup).toContain(
|
|
"h-auto w-full justify-start text-left whitespace-normal rounded-2xl px-3 py-2 text-[11px] leading-relaxed",
|
|
);
|
|
});
|
|
|
|
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 previewGridClassNames = [...markup.matchAll(
|
|
/class="([^"]*\bmt-3\b[^"]*\bgrid\b[^"]*\bgap-2\b[^"]*)"/g,
|
|
)].map(([, className]) => className);
|
|
const multiColumnPreviewPattern =
|
|
/(?:^|\s)(?:[a-z]+:)*(?:grid-cols-(?:\[[^\]]+\]|\S+)|grid-flow-col|auto-cols-(?:\[[^\]]+\]|\S+)|columns-(?:\[[^\]]+\]|\S+)|overflow-x-auto|flex-row)/;
|
|
|
|
expect(markup).toContain('class="mt-3 grid gap-2"');
|
|
expect(markup).toContain("Attached image 1");
|
|
expect(markup).toContain("Attached image 2");
|
|
expect(previewGridClassNames).toHaveLength(1);
|
|
expect(previewGridClassNames[0]).not.toMatch(multiColumnPreviewPattern);
|
|
});
|
|
});
|
|
|
|
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("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("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();
|
|
});
|
|
});
|