test(ai-toolbar): verify paste listener gating at component boundary

This commit is contained in:
2026-04-22 23:15:29 -04:00
parent 9f23597e53
commit 8cc868c22a
2 changed files with 130 additions and 75 deletions

View File

@@ -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