Closes #1577 Extends the deep link (#1576) to land on a rendered MCP App: - `&openApp=<toolName>` — once connected and the tool appears in the app list, InspectorView switches to the Apps tab and pre-selects it. - `&appArgs=<base64url(JSON)>` — pre-fills the tool form; values are merged **over** `collectSchemaDefaults()` so required-with-default fields don't disable "Open App". - `&autoOpen=<token>` — token-gated auto-click of "Open App". Adapted to the current architecture (differs from PR #1510's reference): - Tab state is lifted to App.tsx (#1417), so the openApp effect lives in InspectorView and drives `onActiveTabChange("Apps")` + `onAppsUiChange` seed + `onSelectApp`, guarded by `deepLinkOpenAppRef` to fire once. - AppsScreen owns the `running`/iframe state, so `autoOpen` is threaded to it as a prop; a fire-once effect defers the open one microtask past the synchronous effect body (satisfying `react-hooks/set-state-in-effect`, matching App.tsx's connect effect). `resetAppChannels` memoized so the effect's deps don't churn. Tests: InspectorView (tab-switch + pre-select + appArgs-over-defaults merge + non-app openApp ignored), AppsScreen (autoOpen fires once with seeded values; no-selection stays idle). README documents the params. Coverage gate green. Part of the #1579 decomposition (Wave 4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
846 lines
33 KiB
TypeScript
846 lines
33 KiB
TypeScript
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<string, ((payload: unknown) => 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<Record<string, unknown>> {
|
|
const onmessage = bridges.at(-1)?.onmessage as unknown as
|
|
| ((params: {
|
|
role: "user";
|
|
content: ContentBlock[];
|
|
}) => Promise<Record<string, unknown>>)
|
|
| undefined;
|
|
if (!onmessage) throw new Error("no onmessage handler attached");
|
|
let result: Record<string, unknown> = {};
|
|
await act(async () => {
|
|
result = await onmessage({ role: "user", content });
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function buildProps(overrides: Partial<AppsScreenProps> = {}): 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,<title>sandbox</title>",
|
|
bridgeFactory: okBridgeFactory,
|
|
rendererRef: createRef<AppRendererHandle>(),
|
|
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<AppsScreenProps> = {}) {
|
|
const props = buildProps(overrides);
|
|
const [ui, setUi] = useState<AppsUiState>({
|
|
...EMPTY_APPS_UI,
|
|
...props.ui,
|
|
});
|
|
return (
|
|
<AppsScreen
|
|
{...props}
|
|
ui={ui}
|
|
onUiChange={(next) => {
|
|
setUi(next);
|
|
props.onUiChange(next);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
describe("AppsScreen", () => {
|
|
it("renders the empty selection state", () => {
|
|
renderWithMantine(<AppsScreen {...buildProps()} />);
|
|
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(<AppsScreen {...buildProps({ tools: [] })} />);
|
|
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(
|
|
<AppsScreen {...buildProps({ sandboxPath: undefined })} />,
|
|
);
|
|
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(
|
|
<ControlledAppsScreen
|
|
bridgeFactory={throwingFactory}
|
|
onError={onError}
|
|
/>,
|
|
);
|
|
// 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(<ControlledAppsScreen />);
|
|
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(<ControlledAppsScreen />);
|
|
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(
|
|
<ControlledAppsScreen onSelectApp={onSelectApp} onOpenApp={onOpenApp} />,
|
|
);
|
|
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(<ControlledAppsScreen onOpenApp={onOpenApp} />);
|
|
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(
|
|
<ControlledAppsScreen
|
|
autoOpen
|
|
ui={{
|
|
selectedAppName: "weather",
|
|
formValues: { city: "Reykjavik" },
|
|
search: "",
|
|
}}
|
|
onOpenApp={onOpenApp}
|
|
/>,
|
|
);
|
|
// 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(<ControlledAppsScreen autoOpen onOpenApp={onOpenApp} />);
|
|
// 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(<ControlledAppsScreen onOpenApp={onOpenApp} />);
|
|
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(<ControlledAppsScreen onOpenApp={onOpenApp} />);
|
|
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(<ControlledAppsScreen />);
|
|
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(<ControlledAppsScreen />);
|
|
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(<ControlledAppsScreen />);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
// 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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(<ControlledAppsScreen onCloseApp={onCloseApp} />);
|
|
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(<ControlledAppsScreen onSelectApp={onSelectApp} />);
|
|
// 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(<ControlledAppsScreen />);
|
|
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(
|
|
<AppsScreen {...buildProps({ listChanged: true, onRefreshList })} />,
|
|
);
|
|
// 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(<AppsScreen {...buildProps()} />);
|
|
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(<ControlledAppsScreen tools={[iconedApp]} />);
|
|
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(
|
|
<ControlledAppsScreen onSelectApp={onSelectApp} />,
|
|
);
|
|
// 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(
|
|
<ControlledAppsScreen
|
|
onSelectApp={onSelectApp}
|
|
tools={[noFieldsApp, cohortApp]}
|
|
/>,
|
|
);
|
|
// 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(
|
|
<ControlledAppsScreen
|
|
ui={{ ...EMPTY_APPS_UI, selectedAppName: "weather" }}
|
|
/>,
|
|
);
|
|
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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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(
|
|
<ControlledAppsScreen
|
|
bridgeFactory={failingFactory}
|
|
onError={onError}
|
|
/>,
|
|
);
|
|
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(
|
|
<ControlledAppsScreen bridgeFactory={failingFactory} />,
|
|
);
|
|
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(<ControlledAppsScreen />);
|
|
// 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<string, unknown>[] = [];
|
|
let onInitialized: (() => void) | undefined;
|
|
let sawInput = false;
|
|
const factory: BridgeFactory = () => {
|
|
const listeners: Record<string, ((p: unknown) => void)[]> = {};
|
|
const bridge = {
|
|
sendToolInput: async () => {
|
|
sawInput = true;
|
|
},
|
|
sendToolInputPartial: async (params: {
|
|
arguments?: Record<string, unknown>;
|
|
}) => {
|
|
// 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(<ControlledAppsScreen bridgeFactory={factory} />);
|
|
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);
|
|
});
|
|
});
|