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

309 lines
8.2 KiB
TypeScript

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 registerAIToolbarEffectHooks = () => {
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 registerAIToolbarMarkupHooks = () => {
beforeEach(() => {
globalThis.document = {
addEventListener: () => {},
removeEventListener: () => {},
} as Document;
mock.module("@/hooks/use-mobile", () => ({
useIsMobile: () => false,
}));
});
afterEach(() => {
delete (globalThis as { document?: Document }).document;
delete (globalThis as { navigator?: Navigator }).navigator;
mock.restore();
});
};
export const registerAIToolbarTestHooks = registerAIToolbarEffectHooks;
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 renderToolbarActionBoundary = async (
overrides: Partial<AIToolbarProps> = {},
) => {
type CapturedButton = React.ComponentProps<"button"> & {
label: string;
ariaLabel?: string;
};
const buttons: CapturedButton[] = [];
mock.module("@/components/ui/button", () => ({
Button: ({ children, ...props }: React.ComponentProps<"button">) => {
const label = renderToStaticMarkup(
actualReact.createElement(actualReact.Fragment, null, children),
)
.replace(/<[^>]+>/g, "")
.trim();
buttons.push({
...props,
label,
ariaLabel: props["aria-label"] as string | undefined,
});
return actualReact.createElement("button", props, children);
},
}));
const { AIToolbar } = await import("@/components/ai-toolbar");
renderToStaticMarkup(
actualReact.createElement(AIToolbar, createToolbarProps(overrides)),
);
return {
buttons,
getButtonByLabel: (label: string) => {
const button = buttons.find((entry) => entry.label.includes(label));
expect(button).toBeDefined();
return button!;
},
getButtonByAriaLabel: (ariaLabel: string) => {
const button = buttons.find((entry) => entry.ariaLabel === ariaLabel);
expect(button).toBeDefined();
return button!;
},
};
};
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);