import { StrictMode, type ReactNode } from "react"; import { renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { composerIdentity, composerInsertionText, useStablePluginComposerCapability, } from "./composer-capability"; /** One mounted task-chat composer, used wherever the identity is incidental. */ const COMPOSER = "task-chat:t1:s1"; function strictWrapper({ children }: { children: ReactNode }) { return {children}; } describe("composerInsertionText", () => { it("trims and ignores an empty transcript", () => { expect(composerInsertionText(" ", "a")).toBe(""); expect(composerInsertionText("", "")).toBe(""); }); it("adds one space only when the preceding character is not whitespace", () => { expect(composerInsertionText(" world ", "o")).toBe(" world"); expect(composerInsertionText("world", " ")).toBe("world"); expect(composerInsertionText("world", "\n")).toBe("world"); expect(composerInsertionText("world", "")).toBe("world"); }); }); describe("composerIdentity", () => { it("separates surfaces, tasks and sessions", () => { const ids = [ composerIdentity("task-chat", "t1", "s1"), composerIdentity("task-chat", "t1", "s2"), composerIdentity("task-chat", "t2", "s1"), composerIdentity("quick-chat", "t1", "s1"), composerIdentity("task-create", null, null), composerIdentity("new-session", "t1", null), ]; expect(new Set(ids).size).toBe(ids.length); }); it("is stable for the same composer", () => { expect(composerIdentity("task-chat", "t1", "s1")).toBe( composerIdentity("task-chat", "t1", "s1"), ); }); }); describe("useStablePluginComposerCapability", () => { it("keeps one capability object across re-renders", () => { const { result, rerender } = renderHook( (submittable: boolean) => useStablePluginComposerCapability( { insertText: () => true, focus: () => true, submit: async () => submittable, }, COMPOSER, ), { initialProps: false }, ); const first = result.current; rerender(true); expect(result.current).toBe(first); }); it("revalidates the native gate at call time rather than at handout time", async () => { const { result, rerender } = renderHook( (submittable: boolean) => useStablePluginComposerCapability( { insertText: () => true, focus: () => true, submit: async () => submittable, }, COMPOSER, ), { initialProps: false }, ); // A plugin that captured the capability while submit was blocked... const captured = result.current; await expect(captured.submit()).resolves.toEqual({ status: "blocked" }); // ...still submits once the native form becomes submittable. rerender(true); await expect(captured.submit()).resolves.toEqual({ status: "submitted" }); }); it("survives a StrictMode remount so a long-running plugin action still works", async () => { const insertText = vi.fn(() => true); const { result } = renderHook( () => useStablePluginComposerCapability( { insertText, focus: () => true, submit: async () => true, }, COMPOSER, ), { wrapper: strictWrapper }, ); expect(result.current.insertText("hello")).toEqual({ status: "inserted" }); await expect(result.current.submit()).resolves.toEqual({ status: "submitted" }); expect(insertText).toHaveBeenCalledWith("hello"); }); it("revokes the previous handle when the composer's identity changes", async () => { // Task chat is not remounted on a session switch (ChatInputContainer is // keyed only by clarificationKey), so without an identity key a handle // captured on session A would retarget session B's editor. const { result, rerender } = renderHook( (identity: string) => useStablePluginComposerCapability( { insertText: () => true, focus: () => true, submit: async () => true, }, identity, ), { initialProps: COMPOSER }, ); const capturedOnSessionA = result.current; rerender("task-chat:t1:s2"); expect(result.current).not.toBe(capturedOnSessionA); expect(capturedOnSessionA.insertText("late transcript")).toEqual({ status: "unavailable" }); await expect(capturedOnSessionA.submit()).resolves.toEqual({ status: "unavailable" }); // The freshly issued handle drives the new composer normally. expect(result.current.insertText("hello")).toEqual({ status: "inserted" }); }); it("fails closed once the composer unmounts", async () => { const insertText = vi.fn(() => true); const { result, unmount } = renderHook(() => useStablePluginComposerCapability( { insertText, focus: () => true, submit: async () => true, }, COMPOSER, ), ); const captured = result.current; unmount(); expect(captured.insertText("hello")).toEqual({ status: "unavailable" }); expect(captured.focus()).toEqual({ status: "unavailable" }); await expect(captured.submit()).resolves.toEqual({ status: "unavailable" }); expect(insertText).not.toHaveBeenCalled(); }); });