import { createRef, useState } from "react"; import { act } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { ContentBlock, Tool } from "@modelcontextprotocol/sdk/types.js"; import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import { renderWithMantine, screen, within, } from "../../../test/renderWithMantine"; import { AppsScreen, type AppsScreenProps, type AppsUiState, } from "./AppsScreen"; import { EMPTY_APPS_UI } from "../screenUiState"; import type { AppRendererHandle, BridgeFactory, } from "../../elements/AppRenderer/AppRenderer"; const noFieldsApp: Tool = { name: "ops", title: "Ops Dashboard", inputSchema: { type: "object" }, _meta: { ui: { resourceUri: "ui://apps/ops" } }, }; const fieldedApp: Tool = { name: "weather", title: "Weather Widget", inputSchema: { type: "object", properties: { city: { type: "string", description: "City name" }, }, required: ["city"], }, _meta: { ui: { resourceUri: "ui://apps/weather" } }, }; const cohortApp: Tool = { name: "cohorts", title: "Cohort Data", description: "Cohort retention", inputSchema: { type: "object", properties: { metric: { type: "string", default: "retention" } }, }, _meta: { ui: { resourceUri: "ui://apps/cohorts" } }, }; const okBridgeFactory: BridgeFactory = () => ({ sendToolInput: async () => {}, sendToolInputPartial: async () => {}, sendToolResult: async () => {}, sendToolCancelled: async () => {}, sendHostContextChange: async () => {}, addEventListener: () => {}, removeEventListener: () => {}, teardownResource: async () => ({}), close: async () => {}, }) as unknown as AppBridge; // A bridge factory whose bridge supports the addEventListener/emit surface the // AppRenderer wires up, so a test can drive a bridge event (sizechange) through // to the screen's handling. `emit` dispatches to the captured listeners; // `bridges` exposes each built bridge so tests can also drive the per-bridge // handlers (onrequestdisplaymode). function createEventBridgeFactory(): { factory: BridgeFactory; bridges: AppBridge[]; emit: (event: string, payload?: unknown) => void; } { const listeners: Record void)[]> = {}; const bridges: AppBridge[] = []; const factory: BridgeFactory = () => { const bridge = { sendToolInput: async () => {}, sendToolInputPartial: async () => {}, sendToolResult: async () => {}, sendToolCancelled: async () => {}, sendHostContextChange: async () => {}, teardownResource: async () => ({}), close: async () => {}, addEventListener: (event: string, handler: (p: unknown) => void) => { (listeners[event] ??= []).push(handler); }, removeEventListener: () => {}, } as unknown as AppBridge; bridges.push(bridge); return bridge; }; return { factory, bridges, emit: (event, payload) => (listeners[event] ?? []).forEach((h) => h(payload)), }; } // Invoke the onrequestdisplaymode handler the screen attached to the latest // bridge, mimicking a ui/request-display-mode request from the running view. async function requestDisplayMode( bridges: AppBridge[], mode: McpUiDisplayMode, ): Promise<{ mode: McpUiDisplayMode }> { const handler = bridges.at(-1)?.onrequestdisplaymode as unknown as | ((params: { mode: McpUiDisplayMode }) => Promise<{ mode: McpUiDisplayMode; }>) | undefined; if (!handler) throw new Error("no onrequestdisplaymode handler attached"); let result: { mode: McpUiDisplayMode } = { mode }; await act(async () => { result = await handler({ mode }); }); return result; } // Invoke the onmessage handler the screen attached to the latest bridge, // mimicking a ui/message request from the running view. async function sendUiMessage( bridges: AppBridge[], content: ContentBlock[], ): Promise> { const onmessage = bridges.at(-1)?.onmessage as unknown as | ((params: { role: "user"; content: ContentBlock[]; }) => Promise>) | undefined; if (!onmessage) throw new Error("no onmessage handler attached"); let result: Record = {}; await act(async () => { result = await onmessage({ role: "user", content }); }); return result; } function buildProps(overrides: Partial = {}): AppsScreenProps { return { tools: [fieldedApp, noFieldsApp, cohortApp] as Tool[], listChanged: false, // happy-dom would otherwise try to fetch the iframe `src` over the // network. A data URL keeps the AppRenderer mountable without leaving // the test environment. sandboxPath: "data:text/html,sandbox", bridgeFactory: okBridgeFactory, rendererRef: createRef(), ui: EMPTY_APPS_UI, onUiChange: vi.fn(), onRefreshList: vi.fn(), onSelectApp: vi.fn(), onOpenApp: vi.fn(), onCloseApp: vi.fn(), ...overrides, }; } // AppsScreen lifts selection, form values, and the sidebar search to the // parent (App) as one `ui` object so they persist across tab navigation // (#1417), while `running`/`maximized` stay local to the screen. This host // holds the lifted state so clicking an app, typing into its form/search, and // closing drive the panel exactly as App owns it. The internal running/maximized // state handles auto-launch, open, maximize, and back-to-input on its own. Props // passed in override defaults; the stateful `ui` wiring is applied last so // callers can still observe activity via the rendered state. function ControlledAppsScreen(overrides: Partial = {}) { const props = buildProps(overrides); const [ui, setUi] = useState({ ...EMPTY_APPS_UI, ...props.ui, }); return ( { setUi(next); props.onUiChange(next); }} /> ); } describe("AppsScreen", () => { it("renders the empty selection state", () => { renderWithMantine(); expect(screen.getByText("Select an app to view details")).toBeVisible(); expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); }); it("shows 'No apps available' when the tool list is empty", () => { renderWithMantine(); expect(screen.getByText("No apps available")).toBeInTheDocument(); expect(screen.getByText("MCP Apps (0)")).toBeInTheDocument(); }); it("renders the unavailable state when no sandbox path is provided", () => { renderWithMantine( , ); expect(screen.getByText(/MCP Apps are unavailable/i)).toBeInTheDocument(); // The sidebar / app list is not rendered in the unavailable state. expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument(); }); it("surfaces a bridge factory failure via onError", async () => { const user = userEvent.setup(); const onError = vi.fn(); const throwingFactory: BridgeFactory = () => { throw new Error("no connected MCP client"); }; renderWithMantine( , ); // The no-fields app auto-launches on selection, mounting the renderer, // whose effect invokes the factory (which throws → routes to onError). await user.click(screen.getByText("Ops Dashboard")); await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); expect((onError.mock.calls[0][0] as Error).message).toContain( "no connected MCP client", ); }); it("filters the list via the search input", async () => { const user = userEvent.setup(); renderWithMantine(); await user.type(screen.getByPlaceholderText("Search apps..."), "weather"); expect(screen.getByText("Weather Widget")).toBeInTheDocument(); expect(screen.queryByText("Ops Dashboard")).not.toBeInTheDocument(); }); it("shows 'No matching apps' when search yields no results", async () => { const user = userEvent.setup(); renderWithMantine(); await user.type(screen.getByPlaceholderText("Search apps..."), "zzz"); expect(screen.getByText("No matching apps")).toBeInTheDocument(); }); it("opens the detail panel when a fielded app is selected", async () => { const user = userEvent.setup(); const onSelectApp = vi.fn(); const onOpenApp = vi.fn(); renderWithMantine( , ); await user.click(screen.getByText("Weather Widget")); expect(onSelectApp).toHaveBeenCalledWith("weather"); expect(onOpenApp).not.toHaveBeenCalled(); expect( screen.getByRole("button", { name: /Open App/ }), ).toBeInTheDocument(); }); it("auto-launches a no-fields app on selection", async () => { const user = userEvent.setup(); const onOpenApp = vi.fn(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); expect(onOpenApp).toHaveBeenCalledWith("ops", {}); // Renderer iframe replaces the form; Open App button is gone. expect( screen.queryByRole("button", { name: /Open App/ }), ).not.toBeInTheDocument(); expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument(); }); it("auto-opens the pre-selected app with its seeded form values when autoOpen is set (deep-link)", async () => { const onOpenApp = vi.fn(); renderWithMantine( , ); // No click: the fire-once effect opens the seeded app with its form values. await vi.waitFor(() => expect(onOpenApp).toHaveBeenCalledWith("weather", { city: "Reykjavik" }), ); expect(onOpenApp).toHaveBeenCalledTimes(1); // Renderer mounted; the Open App button is gone. expect( screen.queryByRole("button", { name: /Open App/ }), ).not.toBeInTheDocument(); expect(screen.getByTitle("Weather Widget")).toBeInTheDocument(); }); it("does not auto-open when autoOpen is set but no app is pre-selected", async () => { const onOpenApp = vi.fn(); renderWithMantine(); // Give the effect a chance to run; with no selection it must stay idle. await Promise.resolve(); expect(onOpenApp).not.toHaveBeenCalled(); expect(screen.getByText("Select an app to view details")).toBeVisible(); }); it("invokes onOpenApp with form values when Open App is clicked", async () => { const user = userEvent.setup(); const onOpenApp = vi.fn(); renderWithMantine(); await user.click(screen.getByText("Weather Widget")); const cityField = screen.getByRole("textbox", { name: /city/i }); await user.type(cityField, "Reykjavik"); await user.click(screen.getByRole("button", { name: /Open App/ })); expect(onOpenApp).toHaveBeenCalledWith("weather", { city: "Reykjavik" }); expect(screen.getByTitle("Weather Widget")).toBeInTheDocument(); }); it("seeds schema defaults so untouched fields are sent on Open App", async () => { const user = userEvent.setup(); const onOpenApp = vi.fn(); renderWithMantine(); await user.click(screen.getByText("Cohort Data")); // Open without editing the metric field: its default must still be sent. await user.click(screen.getByRole("button", { name: /Open App/ })); expect(onOpenApp).toHaveBeenCalledWith("cohorts", { metric: "retention" }); }); it("returns to the input form when 'Back to Input' is clicked", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("Weather Widget")); await user.type( screen.getByRole("textbox", { name: /city/i }), "Reykjavik", ); await user.click(screen.getByRole("button", { name: /Open App/ })); await user.click(screen.getByRole("button", { name: /Back to Input/ })); expect( screen.getByRole("button", { name: /Open App/ }), ).toBeInTheDocument(); }); it("does not show 'Back to Input' for a no-fields app", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); expect( screen.queryByRole("button", { name: /Back to Input/ }), ).not.toBeInTheDocument(); }); it("toggles maximize, hiding the sidebar", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); await user.click(screen.getByLabelText("Maximize")); expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument(); await user.click(screen.getByLabelText("Restore")); expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); }); it("sizes the renderer frame to the view-reported height", async () => { const user = userEvent.setup(); const { factory, emit } = createEventBridgeFactory(); renderWithMantine(); // Auto-launches the no-fields app, mounting the renderer and registering // its sizechange listener once the bridge resolves. await user.click(screen.getByText("Ops Dashboard")); const iframe = screen.getByTitle("Ops Dashboard"); const frame = iframe.parentElement as HTMLElement; // Until the view reports a size, the frame flex-grows to fill the card. expect(frame.style.flexGrow).toBe("1"); await act(async () => { // Let the synchronous bridge factory's promise chain settle so the // sizechange listener is registered before we emit. await Promise.resolve(); await Promise.resolve(); emit("sizechange", { height: 600 }); }); // A reported height switches the frame to its content size: it stops // flex-growing and takes the explicit `h`. expect(frame.style.flexGrow).toBe("0"); }); it("ignores a size-changed report that carries no height", async () => { const user = userEvent.setup(); const { factory, emit } = createEventBridgeFactory(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); const frame = screen.getByTitle("Ops Dashboard") .parentElement as HTMLElement; await act(async () => { await Promise.resolve(); await Promise.resolve(); emit("sizechange", { width: 400 }); }); // No height in the report — the frame keeps flex-growing. expect(frame.style.flexGrow).toBe("1"); }); it("ignores a size-changed report with a non-positive height", async () => { const user = userEvent.setup(); const { factory, emit } = createEventBridgeFactory(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); const frame = screen.getByTitle("Ops Dashboard") .parentElement as HTMLElement; await act(async () => { await Promise.resolve(); await Promise.resolve(); // A transient 0 (pre-layout / teardown) must not collapse the frame. emit("sizechange", { height: 0 }); }); expect(frame.style.flexGrow).toBe("1"); // A subsequent positive report is honored. await act(async () => emit("sizechange", { height: 480 })); expect(frame.style.flexGrow).toBe("0"); }); it("maximizes the frame when the view requests fullscreen via request-display-mode", async () => { const user = userEvent.setup(); const { factory, bridges } = createEventBridgeFactory(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); const result = await requestDisplayMode(bridges, "fullscreen"); expect(result).toEqual({ mode: "fullscreen" }); // Fullscreen hides the sidebar (same effect as the Maximize button). expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument(); }); it("declines an unsupported display mode, returning the current mode", async () => { const user = userEvent.setup(); const { factory, bridges } = createEventBridgeFactory(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); // "pip" is not in HOST_AVAILABLE_DISPLAY_MODES — declined, current mode // ("inline") returned, and the sidebar stays visible. const result = await requestDisplayMode(bridges, "pip"); expect(result).toEqual({ mode: "inline" }); expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); }); it("restores inline via request-display-mode after maximizing", async () => { const user = userEvent.setup(); const { factory, bridges } = createEventBridgeFactory(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); await requestDisplayMode(bridges, "fullscreen"); expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument(); const result = await requestDisplayMode(bridges, "inline"); expect(result).toEqual({ mode: "inline" }); expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); }); it("surfaces ui/message content from the view in the message log", async () => { const user = userEvent.setup(); const { factory, bridges } = createEventBridgeFactory(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); await vi.waitFor(() => expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"), ); await sendUiMessage(bridges, [ { type: "text", text: "hello from the app" }, ]); expect(screen.getByText(/Messages from app \(1\)/)).toBeInTheDocument(); expect(screen.getByText(/hello from the app/)).toBeInTheDocument(); expect(screen.getByTestId("apps-messages")).toBeInTheDocument(); }); it("returns an empty ui/message result (no conversation content leak)", async () => { const user = userEvent.setup(); const { factory, bridges } = createEventBridgeFactory(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); await vi.waitFor(() => expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"), ); const result = await sendUiMessage(bridges, [ { type: "text", text: "secret" }, ]); expect(result).toEqual({}); }); it("clears the message log when the app is closed", async () => { const user = userEvent.setup(); const { factory, bridges } = createEventBridgeFactory(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); await vi.waitFor(() => expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"), ); await sendUiMessage(bridges, [ { type: "text", text: "hello from the app" }, ]); expect(screen.getByText(/Messages from app/)).toBeInTheDocument(); await user.click(screen.getByLabelText("Close")); expect(screen.queryByText(/Messages from app/)).not.toBeInTheDocument(); }); it("surfaces app log notifications in a default-expanded collapsible panel with a working Clear", async () => { const user = userEvent.setup(); const { factory, emit } = createEventBridgeFactory(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(screen.queryByText(/App logs/)).not.toBeInTheDocument(); await act(async () => { emit("loggingmessage", { level: "warning", data: "disk almost full" }); emit("loggingmessage", { level: "error", logger: "render", data: { code: 500 }, }); }); const toggle = screen.getByRole("button", { name: /App logs \(2\)/ }); expect(toggle).toBeInTheDocument(); expect(toggle.getAttribute("aria-expanded")).toBe("true"); // Default-expanded: entries are visible without clicking the toggle. expect(screen.getByText("disk almost full")).toBeInTheDocument(); expect(screen.getByText("render")).toBeInTheDocument(); expect(screen.getByText('{"code":500}')).toBeInTheDocument(); expect(screen.getByTestId("apps-logs")).toBeInTheDocument(); // Collapsing still works, and Clear empties the panel. await user.click(toggle); expect(toggle.getAttribute("aria-expanded")).toBe("false"); // The toggle points at the collapse region for assistive tech. expect(toggle.getAttribute("aria-controls")).toBe("apps-logs-region"); await user.click(screen.getByRole("button", { name: "Clear" })); expect(screen.queryByText(/App logs/)).not.toBeInTheDocument(); }); it("renders a log notification that carries no data without crashing", async () => { const user = userEvent.setup(); const { factory, emit } = createEventBridgeFactory(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); await act(async () => { await Promise.resolve(); await Promise.resolve(); // No `data` field — formatLogData must coalesce to "" (not "undefined"). emit("loggingmessage", { level: "info" }); }); expect( screen.getByRole("button", { name: /App logs \(1\)/ }), ).toBeInTheDocument(); }); it("caps retained app logs at the soft limit, dropping the oldest", async () => { const user = userEvent.setup(); const { factory, emit } = createEventBridgeFactory(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); await act(async () => { await Promise.resolve(); await Promise.resolve(); // 501 entries exceeds the 500 cap → oldest dropped, count clamps at 500. for (let i = 0; i < 501; i++) { emit("loggingmessage", { level: "info", data: `log-${i}` }); } }); expect( screen.getByRole("button", { name: /App logs \(500\)/ }), ).toBeInTheDocument(); // The very first entry was dropped; the most recent is retained. expect(screen.queryByText("log-0")).not.toBeInTheDocument(); expect(screen.getByText("log-500")).toBeInTheDocument(); }); it("calls onCloseApp and clears selection on Close", async () => { const user = userEvent.setup(); const onCloseApp = vi.fn(); renderWithMantine(); await user.click(screen.getByText("Ops Dashboard")); await user.click(screen.getByLabelText("Close")); expect(onCloseApp).toHaveBeenCalledTimes(1); expect(screen.getByText("Select an app to view details")).toBeVisible(); }); it("ignores re-clicking the same app (no duplicate onSelectApp)", async () => { const user = userEvent.setup(); const onSelectApp = vi.fn(); renderWithMantine(); // After the first click "Weather Widget" appears both in the sidebar // list item and the right-pane header, so target the sidebar entry // explicitly via the list-item button role. const listItem = screen.getByRole("button", { name: /Weather Widget/ }); await user.click(listItem); await user.click(listItem); expect(onSelectApp).toHaveBeenCalledTimes(1); }); it("resets form state when switching to a different app", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("Weather Widget")); await user.type( screen.getByRole("textbox", { name: /city/i }), "Reykjavik", ); await user.click(screen.getByText("Cohort Data")); // Cohort form is fresh; Reykjavik (the previous tool's input) is gone. expect(screen.queryByDisplayValue("Reykjavik")).not.toBeInTheDocument(); expect( screen.getByRole("button", { name: /Open App/ }), ).toBeInTheDocument(); }); it("renders a single Refresh via the ListChangedIndicator when listChanged is true", async () => { const user = userEvent.setup(); const onRefreshList = vi.fn(); renderWithMantine( , ); // The indicator's Refresh is the only refresh affordance — no standalone // toolbar button duplicating it. expect(screen.getByText("List updated")).toBeInTheDocument(); const refreshButtons = screen.getAllByRole("button", { name: "Refresh" }); expect(refreshButtons).toHaveLength(1); await user.click(refreshButtons[0]); expect(onRefreshList).toHaveBeenCalledTimes(1); }); it("shows no Refresh button when listChanged is false", () => { renderWithMantine(); expect( screen.queryByRole("button", { name: "Refresh" }), ).not.toBeInTheDocument(); }); it("renders the tool icon next to the header title when present", async () => { const user = userEvent.setup(); const iconSrc = "data:image/svg+xml,%3Csvg/%3E"; const iconedApp: Tool = { name: "weather-with-icon", title: "Weather (Iconed)", icons: [{ src: iconSrc }], inputSchema: { type: "object" }, _meta: { ui: { resourceUri: "ui://apps/weather-iconed" } }, }; renderWithMantine(); await user.click(screen.getByText("Weather (Iconed)")); const headerImg = screen .getAllByRole("presentation") .find((img) => img.getAttribute("src") === iconSrc); expect(headerImg).toBeDefined(); }); it("ignores selection of an unknown tool name (defensive)", async () => { const user = userEvent.setup(); const onSelectApp = vi.fn(); const { rerender } = renderWithMantine( , ); // Click an item, then re-render with a tools list that no longer // contains it: the selection state stays put, but a follow-up click // on the same name no-ops because the lookup fails. await user.click(screen.getByText("Weather Widget")); rerender( , ); // Sidebar no longer shows Weather Widget; the right pane falls back // to the empty selection state since the lookup misses. expect( within(screen.getByText("MCP Apps (2)").parentElement!).queryByText( "Weather Widget", ), ).not.toBeInTheDocument(); expect(screen.getByText("Select an app to view details")).toBeVisible(); }); describe("data-app-status / data-app-error", () => { function getStatus(): string | null { return screen.getByTestId("apps-form").getAttribute("data-app-status"); } it("is idle when no app is running", () => { renderWithMantine( , ); expect(getStatus()).toBe("idle"); }); it("transitions idle → loading → ready around the view's initialized signal", async () => { const user = userEvent.setup(); const { factory, emit } = createEventBridgeFactory(); renderWithMantine(); expect(getStatus()).toBe("idle"); await user.click(screen.getByText("Ops Dashboard")); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(getStatus()).toBe("loading"); await act(async () => emit("initialized")); expect(getStatus()).toBe("ready"); }); it("reports error status + surfaces the reason in an error panel and data-app-error when the bridge factory rejects", async () => { const user = userEvent.setup(); const onError = vi.fn(); const failingFactory: BridgeFactory = () => Promise.reject(new Error("connect refused")); renderWithMantine( , ); await user.click(screen.getByText("Ops Dashboard")); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(getStatus()).toBe("error"); const form = screen.getByTestId("apps-form"); expect(form.getAttribute("data-app-error")).toBe("connect refused"); // The error panel is shown below the frame with the reason, so the // failure isn't a silent blank frame. expect(screen.getByTestId("apps-error")).toBeInTheDocument(); expect(screen.getByText("App failed to load")).toBeInTheDocument(); expect(screen.getByText("connect refused")).toBeInTheDocument(); // The error is also forwarded to the parent onError. expect(onError).toHaveBeenCalledWith( expect.objectContaining({ message: "connect refused" }), ); }); it("resets to idle and clears the error panel when the running app is closed", async () => { const user = userEvent.setup(); const failingFactory: BridgeFactory = () => Promise.reject(new Error("connect refused")); renderWithMantine( , ); await user.click(screen.getByText("Ops Dashboard")); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(screen.getByTestId("apps-error")).toBeInTheDocument(); await user.click(screen.getByLabelText("Close")); expect(screen.queryByTestId("apps-error")).not.toBeInTheDocument(); }); }); it("stages partial-input snapshots from the form and clears them", async () => { const user = userEvent.setup(); renderWithMantine(); // No-fields apps don't show the control. await user.click(screen.getByText("Ops Dashboard")); expect( screen.queryByRole("button", { name: "Stage partial input" }), ).not.toBeInTheDocument(); // Fielded apps do. await user.click(screen.getByText("Weather Widget")); const stage = screen.getByRole("button", { name: "Stage partial input" }); expect(stage).toBeInTheDocument(); expect(screen.queryByText(/staged/)).not.toBeInTheDocument(); await user.click(stage); await user.click(stage); expect(screen.getByText("2 staged")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Clear staged" })); expect(screen.queryByText(/staged/)).not.toBeInTheDocument(); }); it("replays staged partial inputs before the final tool-input on Open App", async () => { const user = userEvent.setup(); const partials: Record[] = []; let onInitialized: (() => void) | undefined; let sawInput = false; const factory: BridgeFactory = () => { const listeners: Record void)[]> = {}; const bridge = { sendToolInput: async () => { sawInput = true; }, sendToolInputPartial: async (params: { arguments?: Record; }) => { // Assert partials arrive before the final input. expect(sawInput).toBe(false); partials.push(params.arguments ?? {}); }, sendToolResult: async () => {}, sendToolCancelled: async () => {}, sendHostContextChange: async () => {}, teardownResource: async () => ({}), close: async () => {}, addEventListener: (event: string, handler: () => void) => { (listeners[event] ??= []).push(handler); if (event === "initialized") onInitialized = handler; }, removeEventListener: () => {}, } as unknown as AppBridge; return bridge; }; renderWithMantine(); await user.click(screen.getByText("Weather Widget")); // Fill the required field so Open App is enabled, then stage two snapshots. await user.type(screen.getByRole("textbox", { name: /city/i }), "NYC"); const stage = screen.getByRole("button", { name: "Stage partial input" }); await user.click(stage); await user.click(stage); await user.click(screen.getByRole("button", { name: /Open App/ })); await act(async () => { await Promise.resolve(); await Promise.resolve(); onInitialized?.(); }); // Both staged snapshots were replayed (staged partials survive Open). No // final tool-input arrives because the parent's onOpenApp is a no-op here // (App drives the renderer handle), so sawInput stays false — the partials // are what this screen is responsible for. expect(partials).toHaveLength(2); expect(sawInput).toBe(false); }); });