test(ai-toolbar): verify paste listener gating at component boundary
This commit is contained in:
@@ -94,22 +94,6 @@ interface AIToolbarProps {
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
interface GlobalPasteCaptureGate {
|
||||
isAuthenticated: boolean;
|
||||
isPending: boolean;
|
||||
canUseAi: boolean;
|
||||
aiLoading: boolean;
|
||||
}
|
||||
|
||||
export function shouldEnableGlobalPasteCapture({
|
||||
isAuthenticated,
|
||||
isPending,
|
||||
canUseAi,
|
||||
aiLoading,
|
||||
}: GlobalPasteCaptureGate): boolean {
|
||||
return isAuthenticated && !isPending && canUseAi && !aiLoading;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const AIToolbar = ({
|
||||
@@ -170,14 +154,7 @@ export const AIToolbar = ({
|
||||
// focused element or OS clipboard model (X11/Wayland).
|
||||
// This is the approach used by Excalidraw's actionPaste.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldEnableGlobalPasteCapture({
|
||||
isAuthenticated,
|
||||
isPending,
|
||||
canUseAi,
|
||||
aiLoading,
|
||||
})
|
||||
)
|
||||
if (!(isAuthenticated && !isPending && canUseAi && !aiLoading))
|
||||
return;
|
||||
|
||||
// ── Handler 1: paste event (works when textarea is NOT focused) ───────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { shouldEnableGlobalPasteCapture } from "@/components/ai-toolbar";
|
||||
import * as React from "react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { getAiDisabledMessage } from "@/lib/ai-feature-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -55,6 +55,88 @@ const BADGE_POSITION_CLASS = "ml-auto";
|
||||
|
||||
const readToolbarSource = () => readFileSync("src/components/ai-toolbar.tsx", "utf8");
|
||||
|
||||
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;
|
||||
|
||||
beforeEach(() => {
|
||||
documentAddEventListener.mockClear();
|
||||
documentRemoveEventListener.mockClear();
|
||||
lastEffectCleanup = undefined;
|
||||
globalThis.document = {
|
||||
addEventListener: documentAddEventListener,
|
||||
removeEventListener: documentRemoveEventListener,
|
||||
} as Document;
|
||||
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;
|
||||
lastEffectCleanup = undefined;
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
const renderToolbar = async (overrides: Partial<AIToolbarProps> = {}) => {
|
||||
const { AIToolbar } = await import("@/components/ai-toolbar");
|
||||
|
||||
AIToolbar(createToolbarProps(overrides));
|
||||
};
|
||||
|
||||
const getExamplePromptButtonClassName = () => {
|
||||
const source = readToolbarSource();
|
||||
const match = source.match(
|
||||
@@ -172,6 +254,51 @@ describe("Event count badge – positioning contract", () => {
|
||||
});
|
||||
|
||||
describe("AI capture redesign", () => {
|
||||
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 is unavailable or loading", async () => {
|
||||
await renderToolbar({ adminAiEnabled: false });
|
||||
await renderToolbar({ aiEnabled: false });
|
||||
await renderToolbar({ aiLoading: true });
|
||||
|
||||
expect(documentAddEventListener).not.toHaveBeenCalled();
|
||||
expect(documentRemoveEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("composer layout is driven by useIsMobile instead of Tailwind breakpoint classes", () => {
|
||||
const source = readToolbarSource();
|
||||
|
||||
@@ -324,55 +451,6 @@ describe("Keyboard shortcuts – toolbar integration contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Global paste capture – AI availability gate", () => {
|
||||
test("document-level paste handlers only arm when AI is actually usable", () => {
|
||||
expect(
|
||||
shouldEnableGlobalPasteCapture({
|
||||
isAuthenticated: true,
|
||||
isPending: false,
|
||||
canUseAi: true,
|
||||
aiLoading: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldEnableGlobalPasteCapture({
|
||||
isAuthenticated: false,
|
||||
isPending: false,
|
||||
canUseAi: true,
|
||||
aiLoading: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldEnableGlobalPasteCapture({
|
||||
isAuthenticated: true,
|
||||
isPending: true,
|
||||
canUseAi: true,
|
||||
aiLoading: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldEnableGlobalPasteCapture({
|
||||
isAuthenticated: true,
|
||||
isPending: false,
|
||||
canUseAi: false,
|
||||
aiLoading: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldEnableGlobalPasteCapture({
|
||||
isAuthenticated: true,
|
||||
isPending: false,
|
||||
canUseAi: true,
|
||||
aiLoading: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 8: Multi-image thumbnail strip ────────────────────────────────────
|
||||
//
|
||||
// When multiple images are attached, they render as a horizontal scrollable
|
||||
|
||||
Reference in New Issue
Block a user