Skip to content

Commit d40149f

Browse files
ochafikclaude
andcommitted
Add App <-> AppBridge integration tests
- Add minimalistic tests validating communication between App (guest) and AppBridge (host) - Test initialization handshake flow - Test Host -> App notifications (toolInput, toolInputPartial, toolResult, hostContext) - Test App -> Host notifications (sizeChange, log) - Test App -> Host requests (sendMessage, sendOpenLink) - Test ping functionality - Add setHostContext and sendToolInputPartial to AppBridge - Add test script to package.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent ff6f5f0 commit d40149f

File tree

3 files changed

+347
-9
lines changed

3 files changed

+347
-9
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"start:example-mcp-server": "cd examples/simple-server && npm start",
3131
"start": "NODE_ENV=development npm run build && concurrently 'npm run start:example-host' 'npm run start:example-mcp-server'",
3232
"build": "bun build.bun.ts",
33+
"test": "bun test",
3334
"prepare": "npm run build && husky",
3435
"docs": "typedoc",
3536
"docs:watch": "typedoc --watch",

src/app-bridge.test.ts

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
4+
import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js";
5+
import { EmptyResultSchema } from "@modelcontextprotocol/sdk/types.js";
6+
7+
import { App } from "./app";
8+
import { AppBridge, type McpUiHostCapabilities } from "./app-bridge";
9+
10+
/** Wait for pending microtasks to complete */
11+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
12+
13+
/**
14+
* Create a minimal mock MCP client for testing AppBridge.
15+
* Only implements methods that AppBridge calls.
16+
*/
17+
function createMockClient(
18+
serverCapabilities: ServerCapabilities = {},
19+
): Pick<Client, "getServerCapabilities" | "request" | "notification"> {
20+
return {
21+
getServerCapabilities: () => serverCapabilities,
22+
request: async () => ({}),
23+
notification: async () => {},
24+
};
25+
}
26+
27+
const testHostInfo = { name: "TestHost", version: "1.0.0" };
28+
const testAppInfo = { name: "TestApp", version: "1.0.0" };
29+
const testHostCapabilities: McpUiHostCapabilities = {
30+
openLinks: {},
31+
serverTools: {},
32+
logging: {},
33+
};
34+
35+
describe("App <-> AppBridge integration", () => {
36+
let app: App;
37+
let bridge: AppBridge;
38+
let appTransport: InMemoryTransport;
39+
let bridgeTransport: InMemoryTransport;
40+
41+
beforeEach(() => {
42+
[appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair();
43+
app = new App(testAppInfo, {}, { autoResize: false });
44+
bridge = new AppBridge(
45+
createMockClient() as Client,
46+
testHostInfo,
47+
testHostCapabilities,
48+
);
49+
});
50+
51+
afterEach(async () => {
52+
await appTransport.close();
53+
await bridgeTransport.close();
54+
});
55+
56+
describe("initialization handshake", () => {
57+
it("App.connect() triggers bridge.oninitialized", async () => {
58+
let initializedFired = false;
59+
60+
bridge.oninitialized = () => {
61+
initializedFired = true;
62+
};
63+
64+
await bridge.connect(bridgeTransport);
65+
await app.connect(appTransport);
66+
67+
expect(initializedFired).toBe(true);
68+
});
69+
70+
it("App receives host info and capabilities after connect", async () => {
71+
await bridge.connect(bridgeTransport);
72+
await app.connect(appTransport);
73+
74+
const hostInfo = app.getHostVersion();
75+
expect(hostInfo).toEqual(testHostInfo);
76+
77+
const hostCaps = app.getHostCapabilities();
78+
expect(hostCaps).toEqual(testHostCapabilities);
79+
});
80+
81+
it("Bridge receives app info and capabilities after initialization", async () => {
82+
const appCapabilities = { tools: { listChanged: true } };
83+
app = new App(testAppInfo, appCapabilities, { autoResize: false });
84+
85+
await bridge.connect(bridgeTransport);
86+
await app.connect(appTransport);
87+
88+
const appInfo = bridge.getAppVersion();
89+
expect(appInfo).toEqual(testAppInfo);
90+
91+
const appCaps = bridge.getAppCapabilities();
92+
expect(appCaps).toEqual(appCapabilities);
93+
});
94+
});
95+
96+
describe("Host -> App notifications", () => {
97+
beforeEach(async () => {
98+
await bridge.connect(bridgeTransport);
99+
});
100+
101+
it("sendToolInput triggers app.ontoolinput", async () => {
102+
const receivedArgs: unknown[] = [];
103+
app.ontoolinput = (params) => {
104+
receivedArgs.push(params.arguments);
105+
};
106+
107+
await app.connect(appTransport);
108+
await bridge.sendToolInput({ arguments: { location: "NYC" } });
109+
110+
expect(receivedArgs).toEqual([{ location: "NYC" }]);
111+
});
112+
113+
it("sendToolInputPartial triggers app.ontoolinputpartial", async () => {
114+
const receivedArgs: unknown[] = [];
115+
app.ontoolinputpartial = (params) => {
116+
receivedArgs.push(params.arguments);
117+
};
118+
119+
await app.connect(appTransport);
120+
await bridge.sendToolInputPartial({ arguments: { loc: "N" } });
121+
await bridge.sendToolInputPartial({ arguments: { location: "NYC" } });
122+
123+
expect(receivedArgs).toEqual([{ loc: "N" }, { location: "NYC" }]);
124+
});
125+
126+
it("sendToolResult triggers app.ontoolresult", async () => {
127+
const receivedResults: unknown[] = [];
128+
app.ontoolresult = (params) => {
129+
receivedResults.push(params);
130+
};
131+
132+
await app.connect(appTransport);
133+
await bridge.sendToolResult({
134+
content: [{ type: "text", text: "Weather: Sunny" }],
135+
});
136+
137+
expect(receivedResults).toHaveLength(1);
138+
expect(receivedResults[0]).toEqual({
139+
content: [{ type: "text", text: "Weather: Sunny" }],
140+
});
141+
});
142+
143+
it("setHostContext triggers app.onhostcontextchanged", async () => {
144+
const receivedContexts: unknown[] = [];
145+
app.onhostcontextchanged = (params) => {
146+
receivedContexts.push(params);
147+
};
148+
149+
await app.connect(appTransport);
150+
bridge.setHostContext({ theme: "dark" });
151+
await flush();
152+
153+
expect(receivedContexts).toEqual([{ theme: "dark" }]);
154+
});
155+
156+
it("setHostContext only sends changed values", async () => {
157+
const receivedContexts: unknown[] = [];
158+
app.onhostcontextchanged = (params) => {
159+
receivedContexts.push(params);
160+
};
161+
162+
await app.connect(appTransport);
163+
164+
bridge.setHostContext({ theme: "dark", locale: "en-US" });
165+
await flush();
166+
bridge.setHostContext({ theme: "dark", locale: "en-US" }); // No change
167+
await flush();
168+
bridge.setHostContext({ theme: "light", locale: "en-US" }); // Only theme changed
169+
await flush();
170+
171+
expect(receivedContexts).toEqual([
172+
{ theme: "dark", locale: "en-US" },
173+
{ theme: "light" },
174+
]);
175+
});
176+
});
177+
178+
describe("App -> Host notifications", () => {
179+
beforeEach(async () => {
180+
await bridge.connect(bridgeTransport);
181+
});
182+
183+
it("app.sendSizeChange triggers bridge.onsizechange", async () => {
184+
const receivedSizes: unknown[] = [];
185+
bridge.onsizechange = (params) => {
186+
receivedSizes.push(params);
187+
};
188+
189+
await app.connect(appTransport);
190+
await app.sendSizeChange({ width: 400, height: 600 });
191+
192+
expect(receivedSizes).toEqual([{ width: 400, height: 600 }]);
193+
});
194+
195+
it("app.sendLog triggers bridge.onloggingmessage", async () => {
196+
const receivedLogs: unknown[] = [];
197+
bridge.onloggingmessage = (params) => {
198+
receivedLogs.push(params);
199+
};
200+
201+
await app.connect(appTransport);
202+
await app.sendLog({
203+
level: "info",
204+
data: "Test log message",
205+
logger: "TestApp",
206+
});
207+
208+
expect(receivedLogs).toHaveLength(1);
209+
expect(receivedLogs[0]).toMatchObject({
210+
level: "info",
211+
data: "Test log message",
212+
logger: "TestApp",
213+
});
214+
});
215+
});
216+
217+
describe("App -> Host requests", () => {
218+
beforeEach(async () => {
219+
await bridge.connect(bridgeTransport);
220+
});
221+
222+
it("app.sendMessage triggers bridge.onmessage and returns result", async () => {
223+
const receivedMessages: unknown[] = [];
224+
bridge.onmessage = async (params) => {
225+
receivedMessages.push(params);
226+
return {};
227+
};
228+
229+
await app.connect(appTransport);
230+
const result = await app.sendMessage({
231+
role: "user",
232+
content: [{ type: "text", text: "Hello from app" }],
233+
});
234+
235+
expect(receivedMessages).toHaveLength(1);
236+
expect(receivedMessages[0]).toMatchObject({
237+
role: "user",
238+
content: [{ type: "text", text: "Hello from app" }],
239+
});
240+
expect(result).toEqual({});
241+
});
242+
243+
it("app.sendMessage returns error result when handler indicates error", async () => {
244+
bridge.onmessage = async () => {
245+
return { isError: true };
246+
};
247+
248+
await app.connect(appTransport);
249+
const result = await app.sendMessage({
250+
role: "user",
251+
content: [{ type: "text", text: "Test" }],
252+
});
253+
254+
expect(result.isError).toBe(true);
255+
});
256+
257+
it("app.sendOpenLink triggers bridge.onopenlink and returns result", async () => {
258+
const receivedLinks: string[] = [];
259+
bridge.onopenlink = async (params) => {
260+
receivedLinks.push(params.url);
261+
return {};
262+
};
263+
264+
await app.connect(appTransport);
265+
const result = await app.sendOpenLink({ url: "https://example.com" });
266+
267+
expect(receivedLinks).toEqual(["https://example.com"]);
268+
expect(result).toEqual({});
269+
});
270+
271+
it("app.sendOpenLink returns error when host denies", async () => {
272+
bridge.onopenlink = async () => {
273+
return { isError: true };
274+
};
275+
276+
await app.connect(appTransport);
277+
const result = await app.sendOpenLink({ url: "https://blocked.com" });
278+
279+
expect(result.isError).toBe(true);
280+
});
281+
});
282+
283+
describe("ping", () => {
284+
it("App responds to ping from bridge", async () => {
285+
await bridge.connect(bridgeTransport);
286+
await app.connect(appTransport);
287+
288+
// Bridge can send ping via the protocol's request method
289+
const result = await bridge.request(
290+
{ method: "ping", params: {} },
291+
EmptyResultSchema,
292+
);
293+
294+
expect(result).toEqual({});
295+
});
296+
});
297+
});

0 commit comments

Comments
 (0)