test(ai-toolbar): verify paste listener gating at component boundary
This commit is contained in:
@@ -94,22 +94,6 @@ interface AIToolbarProps {
|
|||||||
events: CalendarEvent[];
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const AIToolbar = ({
|
export const AIToolbar = ({
|
||||||
@@ -170,14 +154,7 @@ export const AIToolbar = ({
|
|||||||
// focused element or OS clipboard model (X11/Wayland).
|
// focused element or OS clipboard model (X11/Wayland).
|
||||||
// This is the approach used by Excalidraw's actionPaste.
|
// This is the approach used by Excalidraw's actionPaste.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (!(isAuthenticated && !isPending && canUseAi && !aiLoading))
|
||||||
!shouldEnableGlobalPasteCapture({
|
|
||||||
isAuthenticated,
|
|
||||||
isPending,
|
|
||||||
canUseAi,
|
|
||||||
aiLoading,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// ── Handler 1: paste event (works when textarea is NOT focused) ───────
|
// ── 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 { readFileSync } from "node:fs";
|
||||||
import { shouldEnableGlobalPasteCapture } from "@/components/ai-toolbar";
|
import * as React from "react";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { getAiDisabledMessage } from "@/lib/ai-feature-flags";
|
import { getAiDisabledMessage } from "@/lib/ai-feature-flags";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -55,6 +55,88 @@ const BADGE_POSITION_CLASS = "ml-auto";
|
|||||||
|
|
||||||
const readToolbarSource = () => readFileSync("src/components/ai-toolbar.tsx", "utf8");
|
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 getExamplePromptButtonClassName = () => {
|
||||||
const source = readToolbarSource();
|
const source = readToolbarSource();
|
||||||
const match = source.match(
|
const match = source.match(
|
||||||
@@ -172,6 +254,51 @@ describe("Event count badge – positioning contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AI capture redesign", () => {
|
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", () => {
|
test("composer layout is driven by useIsMobile instead of Tailwind breakpoint classes", () => {
|
||||||
const source = readToolbarSource();
|
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 ────────────────────────────────────
|
// ─── Cycle 8: Multi-image thumbnail strip ────────────────────────────────────
|
||||||
//
|
//
|
||||||
// When multiple images are attached, they render as a horizontal scrollable
|
// When multiple images are attached, they render as a horizontal scrollable
|
||||||
|
|||||||
Reference in New Issue
Block a user