test(ai-toolbar): split coverage and add fallback failure cases
This commit is contained in:
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