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>
1309 lines
43 KiB
TypeScript
1309 lines
43 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|