inspector/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx
cliffhall 58e53d5a92 fix: state every test-gate timeout instead of inheriting a library default
Three of the six Vitest projects ran on Vitest's own testTimeout: 5000 and
five on its hookTimeout/teardownTimeout: 10000, and all 788 Testing Library
waits in the web unit project ran on asyncUtilTimeout: 1000. None of those
numbers was chosen here, and all of them were sized for an idle machine rather
than for three or four concurrent worktree sessions each running a full
local:gate on eight cores. #2292, #2278, #2250, #1942 and #1742 were each that
class, diagnosed one site at a time.

This is the complement of #1596, not a retreat from it: every fix that issue
produced stands, and no race is hidden here. What changes is that a correct,
deterministic test is no longer cut off by a budget nobody picked.

- vitest.shared.mts exports frozen TIMEOUTS / INTEGRATION_TIMEOUTS; all six
  projects spread one of them, clients/launcher importing the shared module for
  the first time to do so.
- Both web projects configure Testing Library's asyncUtilTimeout. The storybook
  one needs its own setup file, deliberately outside .storybook/ and free of
  setProjectAnnotations so the automatic preview-annotation provisioning
  (#1898) is untouched.
- 28 per-suite restatements of the integration project's own budget are gone,
  so raising the shared value now moves every site under it. The handful that
  genuinely differ — cross-process lock contention, a full OAuth round trip —
  keep their budget as a named constant that says why.
- AppRenderer's waitFor no longer asks for exactly its enclosing budget, which
  it could never win (#2292's shape); cli's e2e child timer drops below the
  project budget so the bound carrying the diagnostic is the one that fires.
- The transition auto-settle's slack term is named RAF_SLACK_MS and raised;
  the term derived from HEADER_ANIM_MS is untouched. The two answer to
  different things and must not be scaled as one number.
- The 19 Playwright budgets scattered across three web smokes become four
  named constants in scripts/lib/browser-timeouts.mjs.

scripts/verify-test-timeouts.mjs resolves each project through Vitest itself
and asserts the budgets, the asyncUtilTimeout sites, and that retry stays
unset. It was observed red against the pre-change config: unit/tui/launcher at
testTimeout 5000, five projects at hookTimeout 10000, both asyncUtilTimeout
sites unmet.

Closes #2323

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 08:32:34 -04:00

1309 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createRef, StrictMode } from "react";
import { act, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { CallToolResult, Tool } from "@modelcontextprotocol/client";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import {
AppRenderer,
type AppRendererHandle,
type BridgeFactory,
} from "./AppRenderer";
const tool: Tool = {
name: "cohort_app",
title: "Cohort App",
inputSchema: { type: "object" },
};
interface MockBridge {
sendToolInput: ReturnType<typeof vi.fn>;
sendToolInputPartial: ReturnType<typeof vi.fn>;
sendToolResult: ReturnType<typeof vi.fn>;
sendToolCancelled: ReturnType<typeof vi.fn>;
sendHostContextChange: ReturnType<typeof vi.fn>;
teardownResource: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
onrequestdisplaymode?: (params: {
mode: "inline" | "fullscreen" | "pip";
}) => Promise<{ mode: "inline" | "fullscreen" | "pip" }>;
onmessage?: (params: {
role: "user";
content: unknown[];
}) => Promise<Record<string, unknown>>;
/** Test helper: dispatch a bridge event (e.g. "initialized") to listeners. */
emit: (event: string, payload?: unknown) => void;
}
function createMockBridge(): MockBridge {
const listeners: Record<string, ((payload: unknown) => void)[]> = {};
return {
sendToolInput: vi.fn().mockResolvedValue(undefined),
sendToolInputPartial: vi.fn().mockResolvedValue(undefined),
sendToolResult: vi.fn().mockResolvedValue(undefined),
sendToolCancelled: vi.fn().mockResolvedValue(undefined),
sendHostContextChange: vi.fn().mockResolvedValue(undefined),
teardownResource: vi.fn().mockResolvedValue({}),
close: vi.fn().mockResolvedValue(undefined),
addEventListener: vi.fn((event: string, handler: (p: unknown) => void) => {
(listeners[event] ??= []).push(handler);
}),
removeEventListener: vi.fn(
(event: string, handler: (p: unknown) => void) => {
listeners[event] = (listeners[event] ?? []).filter(
(h) => h !== handler,
);
},
),
emit: (event: string, payload?: unknown) => {
(listeners[event] ?? []).forEach((h) => h(payload));
},
};
}
function asBridge(mock: MockBridge): AppBridge {
return mock as unknown as AppBridge;
}
// Two microtask ticks are enough to settle the bridge promise chain in the
// component when the factory is synchronous: tick 1 resolves
// `Promise.resolve(bridgeFactory(iframe))`, tick 2 runs the `.then` that
// assigns `bridgeRef.current`. Tests using a multi-hop async factory must
// flush further inline (see the post-unmount disposal cases).
async function flushAsync(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("AppRenderer", () => {
it("renders an iframe with the sandbox path and tool title", () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
const iframe = screen.getByTitle("Cohort App") as HTMLIFrameElement;
expect(iframe.tagName).toBe("IFRAME");
expect(iframe.getAttribute("src")).toBe("/sandbox.html");
});
it("falls back to tool name when title is missing", () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{
kind: "tool",
tool: { name: "no_title", inputSchema: { type: "object" } },
}}
bridgeFactory={() => asBridge(bridge)}
/>,
);
expect(screen.getByTitle("no_title")).toBeInTheDocument();
});
it("invokes the bridge factory with the iframe element", async () => {
const bridge = createMockBridge();
const factory = vi.fn<BridgeFactory>(() => asBridge(bridge));
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
expect(factory).toHaveBeenCalledTimes(1);
expect(factory.mock.calls[0]?.[0]).toBeInstanceOf(HTMLIFrameElement);
expect(factory.mock.calls[0]?.[1]).toEqual({ kind: "tool", tool });
});
it("forwards sendToolInput through the bridge once initialized", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.sendToolInput({ city: "NYC" });
bridge.emit("initialized");
});
expect(bridge.sendToolInput).toHaveBeenCalledWith({
arguments: { city: "NYC" },
});
});
it("forwards sendToolResult through the bridge once initialized", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
const result: CallToolResult = {
content: [{ type: "text", text: "ok" }],
};
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.sendToolResult(result);
bridge.emit("initialized");
});
expect(bridge.sendToolResult).toHaveBeenCalledWith(result);
});
it("buffers tool input until the view is initialized, then flushes input before result", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
const result: CallToolResult = { content: [{ type: "text", text: "ok" }] };
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
// Pushed before `initialized` — must not reach the bridge yet.
await act(async () => {
await ref.current?.sendToolInput({ city: "NYC" });
await ref.current?.sendToolResult(result);
});
expect(bridge.sendToolInput).not.toHaveBeenCalled();
expect(bridge.sendToolResult).not.toHaveBeenCalled();
// Initialization releases both, input first.
await act(async () => {
bridge.emit("initialized");
});
expect(bridge.sendToolInput).toHaveBeenCalledTimes(1);
expect(bridge.sendToolResult).toHaveBeenCalledTimes(1);
expect(bridge.sendToolInput.mock.invocationCallOrder[0]).toBeLessThan(
bridge.sendToolResult.mock.invocationCallOrder[0],
);
});
it("keeps only the latest buffered input (latest-wins)", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.sendToolInput({ city: "NYC" });
await ref.current?.sendToolInput({ city: "LA" });
bridge.emit("initialized");
});
expect(bridge.sendToolInput).toHaveBeenCalledTimes(1);
expect(bridge.sendToolInput).toHaveBeenCalledWith({
arguments: { city: "LA" },
});
});
it("forwards sendToolCancelled through the bridge", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.sendToolCancelled("user-aborted");
});
expect(bridge.sendToolCancelled).toHaveBeenCalledWith({
reason: "user-aborted",
});
});
it("forwards view size-changed notifications to onSizeChange", async () => {
const bridge = createMockBridge();
const onSizeChange = vi.fn();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
onSizeChange={onSizeChange}
/>,
);
await flushAsync();
await act(async () => {
bridge.emit("sizechange", { width: 480, height: 600 });
});
expect(onSizeChange).toHaveBeenCalledWith({ width: 480, height: 600 });
});
it("does not throw on size-changed when no onSizeChange is provided", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
bridge.emit("sizechange", { height: 320 });
});
expect(screen.getByTitle("Cohort App")).toBeInTheDocument();
});
it("routes ui/request-display-mode to onRequestDisplayMode and returns the applied mode", async () => {
const bridge = createMockBridge();
const onRequestDisplayMode = vi
.fn<(m: "inline" | "fullscreen" | "pip") => "inline" | "fullscreen">()
.mockReturnValue("fullscreen");
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
displayMode="inline"
onRequestDisplayMode={onRequestDisplayMode}
/>,
);
await flushAsync();
await expect(
bridge.onrequestdisplaymode?.({ mode: "fullscreen" }),
).resolves.toEqual({ mode: "fullscreen" });
expect(onRequestDisplayMode).toHaveBeenCalledWith("fullscreen");
});
it("declines ui/request-display-mode by returning the current displayMode when no handler is provided", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
displayMode="fullscreen"
/>,
);
await flushAsync();
await expect(
bridge.onrequestdisplaymode?.({ mode: "pip" }),
).resolves.toEqual({ mode: "fullscreen" });
});
it("declines ui/request-display-mode with inline when neither a handler nor displayMode is set", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await expect(
bridge.onrequestdisplaymode?.({ mode: "pip" }),
).resolves.toEqual({ mode: "inline" });
});
it("replays partialInputs in order before the complete tool-input on initialize", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
partialInputs={[{ city: "N" }, { city: "New" }]}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.sendToolInput({ city: "New York" });
bridge.emit("initialized");
});
expect(bridge.sendToolInputPartial).toHaveBeenCalledTimes(2);
expect(bridge.sendToolInputPartial).toHaveBeenNthCalledWith(1, {
arguments: { city: "N" },
});
expect(bridge.sendToolInputPartial).toHaveBeenNthCalledWith(2, {
arguments: { city: "New" },
});
expect(
bridge.sendToolInputPartial.mock.invocationCallOrder[1],
).toBeLessThan(bridge.sendToolInput.mock.invocationCallOrder[0]);
});
it("sends no tool-input-partial when partialInputs is omitted", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.sendToolInput({ city: "NYC" });
bridge.emit("initialized");
});
expect(bridge.sendToolInputPartial).not.toHaveBeenCalled();
});
it("forwards MCP log notifications to onLog", async () => {
const bridge = createMockBridge();
const onLog = vi.fn();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
onLog={onLog}
/>,
);
await flushAsync();
await act(async () => {
bridge.emit("loggingmessage", { level: "warning", data: "disk full" });
});
expect(onLog).toHaveBeenCalledWith({ level: "warning", data: "disk full" });
});
it("does not throw on a log notification when no onLog is provided", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
bridge.emit("loggingmessage", { level: "info", data: "hi" });
});
expect(screen.getByTitle("Cohort App")).toBeInTheDocument();
});
it("routes ui/message to onMessage and returns the spec-required empty result", async () => {
const bridge = createMockBridge();
const onMessage = vi.fn();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
onMessage={onMessage}
/>,
);
await flushAsync();
const params = {
role: "user" as const,
content: [{ type: "text", text: "hello host" }],
};
await expect(bridge.onmessage?.(params)).resolves.toEqual({});
expect(onMessage).toHaveBeenCalledWith(params);
});
it("declines ui/message with isError when no onMessage handler is provided", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await expect(
bridge.onmessage?.({ role: "user", content: [] }),
).resolves.toEqual({ isError: true });
});
it("pushes a displayMode change to the running view via host-context-changed", async () => {
const bridge = createMockBridge();
// Stable factory identity so the rerender reuses the live bridge instead of
// rebuilding (which would reset `initialized` and gate the push).
const factory: BridgeFactory = () => asBridge(bridge);
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
displayMode="inline"
/>,
);
await flushAsync();
await act(async () => bridge.emit("initialized"));
bridge.sendHostContextChange.mockClear();
rerender(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
displayMode="fullscreen"
/>,
);
await flushAsync();
expect(bridge.sendHostContextChange).toHaveBeenCalledWith({
displayMode: "fullscreen",
});
});
it("does not push a displayMode change before the view is initialized", async () => {
const bridge = createMockBridge();
const factory: BridgeFactory = () => asBridge(bridge);
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
displayMode="inline"
/>,
);
await flushAsync();
bridge.sendHostContextChange.mockClear();
// No `initialized` emitted yet — the push must be gated.
rerender(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
displayMode="fullscreen"
/>,
);
await flushAsync();
expect(bridge.sendHostContextChange).not.toHaveBeenCalledWith({
displayMode: "fullscreen",
});
});
it("pushes a live theme flip (with resolved styles) to the running bridge via host-context-changed", async () => {
const bridge = createMockBridge();
// Stub the computed design tokens so currentStyles() resolves a non-empty
// McpUiHostStyles — exercises the theme observer's styles-included path.
const realGetComputedStyle = window.getComputedStyle;
const getComputedStyleSpy = vi
.spyOn(window, "getComputedStyle")
.mockImplementation((el: Element, pseudo?: string | null) => {
const decl = realGetComputedStyle.call(window, el, pseudo ?? undefined);
return {
...decl,
getPropertyValue: (prop: string) =>
prop === "--mantine-color-body"
? "#101113"
: decl.getPropertyValue(prop),
} as CSSStyleDeclaration;
});
try {
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
// The theme observer is gated on the view's `initialized` signal, so
// complete the handshake before flipping.
await act(async () => bridge.emit("initialized"));
// Ignore any seeding from Mantine's own mount-time write — assert only the
// flip we trigger below.
bridge.sendHostContextChange.mockClear();
await act(async () => {
document.documentElement.setAttribute(
"data-mantine-color-scheme",
"dark",
);
// MutationObserver callbacks are delivered on a microtask.
await Promise.resolve();
});
// `waitFor`, not a bare assertion after the single microtask above: how
// many ticks happy-dom takes to deliver the MutationObserver callback is
// load-dependent, so under a full parallel run the observer can still be
// pending here. Observed failing exactly once that way, with 0 calls.
//
// The default 1000ms then turned out not to be enough either — it failed
// twice in a row in `npm run local:gate` (at ~1021ms, still 0 calls) while
// passing every time this file runs alone, which is the signature of a
// budget that is too tight rather than of a broken observer. Raised
// rather than retried: a `waitFor` that is generous costs nothing on the
// passing path, since it returns as soon as the assertion holds.
await waitFor(() =>
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
expect.objectContaining({
theme: "dark",
styles: expect.objectContaining({
variables: expect.objectContaining({
"--color-background-primary": "#101113",
}),
}),
}),
),
);
} finally {
getComputedStyleSpy.mockRestore();
document.documentElement.removeAttribute("data-mantine-color-scheme");
}
});
it("does not push a theme flip before the view is initialized", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
// No `initialized` emitted — the theme observer is gated, like the
// container and displayMode pushes, so a pre-handshake flip is dropped.
bridge.sendHostContextChange.mockClear();
await act(async () => {
document.documentElement.setAttribute(
"data-mantine-color-scheme",
"dark",
);
await Promise.resolve();
});
expect(bridge.sendHostContextChange).not.toHaveBeenCalled();
document.documentElement.removeAttribute("data-mantine-color-scheme");
});
it("stops observing theme changes after the renderer unmounts", async () => {
const bridge = createMockBridge();
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
unmount();
await Promise.resolve();
await Promise.resolve();
});
bridge.sendHostContextChange.mockClear();
await act(async () => {
document.documentElement.setAttribute(
"data-mantine-color-scheme",
"dark",
);
await Promise.resolve();
});
expect(bridge.sendHostContextChange).not.toHaveBeenCalled();
document.documentElement.removeAttribute("data-mantine-color-scheme");
});
describe("containerDimensions", () => {
// Stub ResizeObserver so tests can drive the callback directly: capture the
// callback + the observed element so its getBoundingClientRect can be
// patched before each fire.
let resizeCallback: (() => void) | undefined;
let observedEl: HTMLElement | undefined;
let originalResizeObserver: typeof ResizeObserver | undefined;
beforeEach(() => {
resizeCallback = undefined;
observedEl = undefined;
originalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
resizeCallback = cb;
}
observe(el: Element) {
observedEl = el as HTMLElement;
}
unobserve() {}
disconnect() {
resizeCallback = undefined;
}
} as unknown as typeof ResizeObserver;
});
afterEach(() => {
if (originalResizeObserver) {
globalThis.ResizeObserver = originalResizeObserver;
} else {
delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
}
});
function stubSize(el: HTMLElement, width: number, height: number) {
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
width,
height,
top: 0,
left: 0,
right: width,
bottom: height,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);
}
it("pushes containerDimensions on initialize when the container has a layout box", async () => {
const bridge = createMockBridge();
const container = document.createElement("div");
stubSize(container, 320, 200);
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
containerRef={{ current: container }}
/>,
);
await flushAsync();
await act(async () => bridge.emit("initialized"));
expect(bridge.sendHostContextChange).toHaveBeenCalledWith({
containerDimensions: { width: 320, height: 200 },
});
});
it("does not push containerDimensions on initialize when the container has no layout box", async () => {
const bridge = createMockBridge();
const container = document.createElement("div");
stubSize(container, 0, 0);
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
containerRef={{ current: container }}
/>,
);
await flushAsync();
// A mount-time theme write may have already pushed a {theme} change;
// clear so we assert only about the initialize-time containerDimensions.
bridge.sendHostContextChange.mockClear();
await act(async () => bridge.emit("initialized"));
expect(bridge.sendHostContextChange).not.toHaveBeenCalled();
});
it("does not push containerDimensions on resize before the view is initialized", async () => {
const bridge = createMockBridge();
const container = document.createElement("div");
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
containerRef={{ current: container }}
/>,
);
await flushAsync();
bridge.sendHostContextChange.mockClear();
stubSize(container, 640, 480);
await act(async () => resizeCallback?.());
expect(bridge.sendHostContextChange).not.toHaveBeenCalled();
});
it("pushes containerDimensions on resize once initialized; skips a 0×0 box and a value-equal repeat", async () => {
const bridge = createMockBridge();
const container = document.createElement("div");
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
containerRef={{ current: container }}
/>,
);
await flushAsync();
await act(async () => bridge.emit("initialized"));
bridge.sendHostContextChange.mockClear();
stubSize(container, 640, 480);
await act(async () => resizeCallback?.());
expect(bridge.sendHostContextChange).toHaveBeenCalledWith({
containerDimensions: { width: 640, height: 480 },
});
bridge.sendHostContextChange.mockClear();
stubSize(container, 640, 480);
await act(async () => resizeCallback?.());
expect(bridge.sendHostContextChange).not.toHaveBeenCalled();
stubSize(container, 0, 0);
await act(async () => resizeCallback?.());
expect(bridge.sendHostContextChange).not.toHaveBeenCalled();
});
it("observes the host-supplied containerRef element instead of the iframe when provided", async () => {
const bridge = createMockBridge();
const container = document.createElement("div");
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
containerRef={{ current: container }}
/>,
);
await flushAsync();
expect(observedEl).toBe(container);
});
it("falls back to observing the iframe when no containerRef is provided", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
expect(observedEl).toBeInstanceOf(HTMLIFrameElement);
});
it("disconnects the ResizeObserver on unmount", async () => {
const bridge = createMockBridge();
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => bridge.emit("initialized"));
await act(async () => {
unmount();
await Promise.resolve();
await Promise.resolve();
});
expect(resizeCallback).toBeUndefined();
});
});
it("builds a single bridge and does not dispose it under StrictMode double-invoke", async () => {
// React StrictMode runs effects setup→cleanup→setup in dev. The bridge
// (a stateful handshake) must survive that as ONE instance — rebuilding it
// spins up a second transport that double-loads the sandbox and races the
// app's ui/initialize handshake.
const bridge = createMockBridge();
const factory = vi.fn<BridgeFactory>(() => asBridge(bridge));
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<StrictMode>
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>
</StrictMode>,
);
await flushAsync();
// One build, and the bridge is NOT torn down by the synthetic remount.
expect(factory).toHaveBeenCalledTimes(1);
expect(bridge.teardownResource).not.toHaveBeenCalled();
expect(bridge.close).not.toHaveBeenCalled();
// The reused bridge still delivers buffered input once initialized.
await act(async () => {
await ref.current?.sendToolInput({ city: "NYC" });
bridge.emit("initialized");
});
expect(bridge.sendToolInput).toHaveBeenCalledTimes(1);
expect(bridge.sendToolInput).toHaveBeenCalledWith({
arguments: { city: "NYC" },
});
});
it("teardown disposes the bridge once and is idempotent", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.teardown();
await ref.current?.teardown();
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
it("does not double-dispose when unmount races an in-flight teardown", async () => {
const bridge = createMockBridge();
let resolveTeardown: ((value: Record<string, unknown>) => void) | undefined;
bridge.teardownResource.mockImplementationOnce(
() =>
new Promise<Record<string, unknown>>((resolve) => {
resolveTeardown = resolve;
}),
);
const ref = createRef<AppRendererHandle>();
const { unmount } = renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
// Kick off teardown but leave its `teardownResource` await pending.
const teardownPromise = ref.current!.teardown();
// Unmount while teardown is in flight.
unmount();
// Resolve the pending teardownResource, then let `close` settle.
await act(async () => {
resolveTeardown?.({});
await teardownPromise;
for (let i = 0; i < 4; i++) {
await Promise.resolve();
}
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
it("send methods are silent no-ops before the bridge resolves", async () => {
const bridge = createMockBridge();
let resolveBridge: ((b: AppBridge) => void) | undefined;
const factory: BridgeFactory = () =>
new Promise<AppBridge>((resolve) => {
resolveBridge = resolve;
});
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
await act(async () => {
await ref.current?.sendToolInput({ city: "NYC" });
await ref.current?.sendToolResult({ content: [] });
await ref.current?.sendToolCancelled("aborted");
await ref.current?.teardown();
});
expect(bridge.sendToolInput).not.toHaveBeenCalled();
expect(bridge.sendToolResult).not.toHaveBeenCalled();
expect(bridge.sendToolCancelled).not.toHaveBeenCalled();
expect(bridge.teardownResource).not.toHaveBeenCalled();
// Resolve to satisfy the pending factory before unmount.
resolveBridge?.(asBridge(bridge));
await flushAsync();
});
it("unmount triggers teardownResource and close", async () => {
const bridge = createMockBridge();
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
unmount();
await Promise.resolve();
await Promise.resolve();
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
it("disposes a bridge that resolves after the component unmounts", async () => {
const bridge = createMockBridge();
let resolveBridge: ((b: AppBridge) => void) | undefined;
const factory: BridgeFactory = () =>
new Promise<AppBridge>((resolve) => {
resolveBridge = resolve;
});
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
unmount();
await act(async () => {
resolveBridge?.(asBridge(bridge));
// disposeBridge awaits teardownResource then close — flush enough
// microtasks to let both calls complete.
for (let i = 0; i < 8; i++) {
await Promise.resolve();
}
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
it("transitions loading -> ready across the view's initialized signal", async () => {
const bridge = createMockBridge();
const onAppStatusChange = vi.fn();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
onAppStatusChange={onAppStatusChange}
/>,
);
await flushAsync();
// The build starts with "loading" before the bridge resolves.
expect(onAppStatusChange).toHaveBeenCalledWith("loading");
expect(onAppStatusChange).not.toHaveBeenCalledWith("ready");
await act(async () => bridge.emit("initialized"));
expect(onAppStatusChange).toHaveBeenLastCalledWith("ready");
});
it("reports status 'error' when the bridge factory throws synchronously", async () => {
const onAppStatusChange = vi.fn();
const factory: BridgeFactory = () => {
throw new Error("sync boom");
};
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
onAppStatusChange={onAppStatusChange}
/>,
);
await flushAsync();
expect(onAppStatusChange).toHaveBeenCalledWith("loading");
expect(onAppStatusChange).toHaveBeenLastCalledWith("error");
});
it("reports status 'error' when the bridge factory rejects", async () => {
const onAppStatusChange = vi.fn();
const factory: BridgeFactory = () =>
Promise.reject(new Error("async boom"));
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
onAppStatusChange={onAppStatusChange}
/>,
);
await flushAsync();
expect(onAppStatusChange).toHaveBeenLastCalledWith("error");
});
it("calls onError when the bridge factory throws", async () => {
const onError = vi.fn();
const factory: BridgeFactory = () => {
throw new Error("boom");
};
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
onError={onError}
/>,
);
await flushAsync();
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect((onError.mock.calls[0]?.[0] as Error).message).toBe("boom");
});
it("wraps non-Error rejections from the factory", async () => {
const onError = vi.fn();
const factory: BridgeFactory = () => Promise.reject("plain string");
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
onError={onError}
/>,
);
await flushAsync();
expect(onError).toHaveBeenCalledTimes(1);
expect((onError.mock.calls[0]?.[0] as Error).message).toBe("plain string");
});
it("does not throw when no onError is provided and the factory fails", async () => {
const factory: BridgeFactory = () => Promise.reject(new Error("ignored"));
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
expect(screen.getByTitle("Cohort App")).toBeInTheDocument();
});
it("rebuilds the bridge when sandboxPath changes", async () => {
const first = createMockBridge();
const second = createMockBridge();
const factory = vi
.fn<BridgeFactory>()
.mockReturnValueOnce(asBridge(first))
.mockReturnValueOnce(asBridge(second));
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox-a.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
rerender(
<AppRenderer
sandboxPath="/sandbox-b.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
expect(factory).toHaveBeenCalledTimes(2);
expect(first.teardownResource).toHaveBeenCalledTimes(1);
expect(first.close).toHaveBeenCalledTimes(1);
});
it("rebuilds the bridge when only the tool changes (same factory and sandboxPath)", async () => {
// Keeps bridgeFactory and sandboxPath stable while swapping the tool, so the
// sameInputs short-circuit evaluates all the way to `prev.tool === tool` and
// takes its false arm — driving a real rebuild, not a reuse.
const first = createMockBridge();
const second = createMockBridge();
const factory = vi
.fn<BridgeFactory>()
.mockReturnValueOnce(asBridge(first))
.mockReturnValueOnce(asBridge(second));
const otherTool: Tool = {
name: "other_app",
title: "Other App",
inputSchema: { type: "object" },
};
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
rerender(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool: otherTool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
expect(factory).toHaveBeenCalledTimes(2);
expect(factory.mock.calls[1]?.[1]).toEqual({
kind: "tool",
tool: otherTool,
});
expect(first.teardownResource).toHaveBeenCalledTimes(1);
expect(first.close).toHaveBeenCalledTimes(1);
});
it("swallows teardownResource errors but still closes the transport", async () => {
const bridge = createMockBridge();
bridge.teardownResource.mockRejectedValueOnce(new Error("teardown failed"));
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
unmount();
await Promise.resolve();
await Promise.resolve();
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
it("swallows close errors during disposal", async () => {
const bridge = createMockBridge();
bridge.close.mockRejectedValueOnce(new Error("close failed"));
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
unmount();
await Promise.resolve();
await Promise.resolve();
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
describe("app-rendered elicitation (#1854)", () => {
/** A bridge that can answer an `elicitation/create` sent through it. */
function elicitationBridge(
result: unknown = { action: "accept", content: { choice: "a" } },
) {
const bridge = createMockBridge();
const withElicitation = Object.assign(bridge, {
getAppCapabilities: () => ({ elicitation: {} }),
request: vi.fn().mockResolvedValue(result),
});
return withElicitation;
}
it("renders an app named by resource URI, using the URI as the frame title", () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "resource", resourceUri: "ui://demo/pick.html" }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
expect(screen.getByTitle("ui://demo/pick.html")).toBeTruthy();
});
it("prefers an explicit title over the URI", () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{
kind: "resource",
resourceUri: "ui://demo/pick.html",
title: "Choose an option",
}}
bridgeFactory={() => asBridge(bridge)}
/>,
);
expect(screen.getByTitle("Choose an option")).toBeTruthy();
});
it("sends the request through the live bridge once the view is initialized", async () => {
const bridge = elicitationBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "resource", resourceUri: "ui://demo/pick.html" }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
act(() => bridge.emit("initialized"));
await expect(
ref.current!.requestElicitation({
message: "Choose",
requestedSchema: { type: "object", properties: {} },
}),
).resolves.toEqual({ action: "accept", content: { choice: "a" } });
});
it("rejects rather than buffering when the view is not ready", async () => {
// Unlike tool input/result there is a server waiting on this, so a
// caller that arrives early must learn now and fall back.
const bridge = elicitationBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
source={{ kind: "resource", resourceUri: "ui://demo/pick.html" }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await expect(
ref.current!.requestElicitation({
message: "Choose",
requestedSchema: { type: "object", properties: {} },
}),
).rejects.toThrow(/not ready/);
expect(bridge.request).not.toHaveBeenCalled();
});
it("keeps a live bridge when the source object is recreated with the same tool", async () => {
// A caller writing the source inline produces a fresh object every
// render; rebuilding on that double-loads the sandbox.
const bridge = createMockBridge();
const factory = vi.fn(() => asBridge(bridge));
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
rerender(
<AppRenderer
sandboxPath="/sandbox.html"
source={{ kind: "tool", tool }}
bridgeFactory={factory}
/>,
);
await flushAsync();
expect(factory).toHaveBeenCalledTimes(1);
});
});
});