import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen } from "@testing-library/react"; let mockActiveTaskId: string | null = "task_1"; let mockActiveSessionId: string | null = "session_1"; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: Record) => unknown) => selector({ tasks: { activeTaskId: mockActiveTaskId, activeSessionId: mockActiveSessionId }, taskSessions: { items: { session_1: { id: "session_1", is_passthrough: false, name: "Session" }, }, }, }), })); import { pluginRegistry } from "@/lib/plugins/registry"; import { PluginTaskPanel } from "./plugin-task-panel"; afterEach(() => { cleanup(); pluginRegistry.unregisterPlugin("plugin-a"); mockActiveTaskId = "task_1"; mockActiveSessionId = "session_1"; }); describe("PluginTaskPanel", () => { it("renders the registered Component with PluginTaskPanelProps (AC2)", () => { function Notes(props: { panelId: string; taskId: string; sessionId: string | null; presentation: string; }) { return (
{props.panelId}|{props.taskId}|{props.sessionId}|{props.presentation}
); } pluginRegistry .forPlugin("plugin-a") .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes }); render( , ); expect(screen.getByTestId("notes-body").textContent).toBe( "plugin:plugin-a:notes|task_1|session_1|desktop", ); }); it("passes session kind and a presentation-scoped navigation facade", () => { const onOpenMessage = vi.fn(() => ({ status: "accepted" as const })); function Notes(props: { sessionKind: string | null; conversation: { openMessage: (messageId: string) => { status: string } }; }) { return ( ); } pluginRegistry .forPlugin("plugin-a") .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes, mobileEnabled: true }); render( , ); screen.getByRole("button", { name: "managed" }).click(); expect(onOpenMessage).toHaveBeenCalledWith("message-1"); }); it("reports unavailable for invalid or rejected mobile navigation", () => { let emptyResult = ""; let rejectedResult = ""; const onOpenMessage = vi.fn(() => ({ status: "unavailable" as const })); function Notes(props: { conversation: { openMessage: (messageId: string) => { status: string } }; }) { return ( <> ); } pluginRegistry .forPlugin("plugin-a") .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes, mobileEnabled: true }); render( , ); screen.getByRole("button", { name: "empty" }).click(); expect(emptyResult).toBe("unavailable"); expect(onOpenMessage).not.toHaveBeenCalled(); screen.getByRole("button", { name: "rejected" }).click(); expect(rejectedResult).toBe("unavailable"); expect(onOpenMessage).toHaveBeenCalledWith("message-1"); }); }); describe("PluginTaskPanel session lifecycle", () => { it("keeps the panel bound to a deleted active session", () => { function Notes(props: { sessionId: string | null }) { return
{props.sessionId}
; } pluginRegistry .forPlugin("plugin-a") .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes }); const view = render( , ); expect(screen.getByTestId("notes-session").textContent).toBe("session_1"); mockActiveSessionId = null; view.rerender( , ); expect(screen.getByTestId("notes-session").textContent).toBe("session_1"); }); }); describe("PluginTaskPanel failure containment", () => { it("fails closed when a managed visibility predicate throws", () => { function Notes() { return
secret panel
; } const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); pluginRegistry.forPlugin("plugin-a").registerTaskPanel({ id: "notes", title: "Notes", Component: Notes, visible: () => { throw new Error("predicate failed"); }, }); render( , ); expect(screen.queryByText("secret panel")).toBeNull(); expect(screen.getByText("This panel is no longer available.")).not.toBeNull(); expect(consoleError).toHaveBeenCalledWith( expect.stringContaining("plugin-a:notes"), expect.any(Error), ); consoleError.mockRestore(); }); it("renders a not-available fallback when the plugin is no longer registered (AC5)", () => { render( , ); expect(screen.getByText("This panel is no longer available.")).not.toBeNull(); }); it("renders the error boundary fallback when the plugin Component throws (AC6)", () => { function Throws(): never { throw new Error("boom"); } pluginRegistry .forPlugin("plugin-a") .registerTaskPanel({ id: "notes", title: "Notes", Component: Throws }); const originalConsoleError = console.error; console.error = () => {}; render( , ); expect(screen.getByText("This plugin panel failed to load.")).not.toBeNull(); console.error = originalConsoleError; }); }); describe("PluginTaskPanel lease", () => { it("revokes a panel navigation lease when visibility changes", () => { let visible = true; let openMessage: ((messageId: string) => { status: string }) | undefined; const onOpenMessage = vi.fn(() => ({ status: "accepted" as const })); function Notes(props: { conversation: { openMessage: (messageId: string) => { status: string } }; }) { openMessage = props.conversation.openMessage; return
visible panel
; } pluginRegistry.forPlugin("plugin-a").registerTaskPanel({ id: "notes", title: "Notes", Component: Notes, mobileEnabled: true, visible: () => visible, }); const view = render( , ); const initialOpenMessage = openMessage; expect(initialOpenMessage?.("message-1").status).toBe("accepted"); expect(onOpenMessage).toHaveBeenCalledWith("message-1"); visible = false; view.rerender( , ); expect(screen.getByText("This panel is no longer available.")).not.toBeNull(); expect(initialOpenMessage?.("message-2").status).toBe("unavailable"); expect(onOpenMessage).toHaveBeenCalledTimes(1); }); });