Add cross-version interop test against the published ext-apps 1.7.5 (#770)

Runs the published 1.7.5 App and AppBridge against the 2.x build over an in-memory transport in both pairings, via an npm alias devDependency and sdk 1.30. Complements wire-compat.test.ts: this one runs the real 1.x code and is the only test that catches a coordinated 2.x wire rename.
This commit is contained in:
Olivier Chafik 2026-09-08 16:17:27 +01:00 • committed by GitHub
commit 12aa50b623
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 579 additions and 1 deletions

198
package-lock.json generated
View file

@ -18,6 +18,7 @@
"@boneskull/typedoc-plugin-mermaid": "^0.2.0",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@modelcontextprotocol/server": "2.0.0",
"@playwright/test": "1.57.0",
"@types/bun": "^1.3.2",
@ -33,6 +34,7 @@
"electron-to-chromium": "1.5.267",
"esbuild": "^0.25.12",
"express": "^5.1.0",
"ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5",
"husky": "^9.1.7",
"nodemon": "^3.1.0",
"playwright": "1.57.0",
@ -2756,6 +2758,47 @@
"resolved": "examples/quickstart",
"link": true
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz",
@ -4375,6 +4418,41 @@
"node": ">=0.4.0"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@ -5937,6 +6015,81 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/ext-apps-v1": {
"name": "@modelcontextprotocol/ext-apps",
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz",
"integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==",
"dev": true,
"license": "MIT",
"workspaces": [
"examples/*"
],
"dependencies": {
"@standard-schema/spec": "^1.1.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@ -6262,7 +6415,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@ -6399,6 +6551,16 @@
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -6586,6 +6748,20 @@
"node": ">=6"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@ -7578,6 +7754,16 @@
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@ -9716,6 +9902,16 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
}
}
}

View file

@ -76,10 +76,12 @@
"update-lock:docker": "rm -rf node_modules package-lock.json examples/*/node_modules && docker run --rm --platform linux/amd64 -v $(pwd):/work -w /work -e HOME=/tmp node:latest npm i --registry=https://registry.npmjs.org/ --ignore-scripts && rm -rf node_modules examples/*/node_modules && npm i --registry=https://registry.npmjs.org/"
},
"author": "Olivier Chafik",
"//interop": "The @modelcontextprotocol/sdk 1.x and ext-apps-v1 devDependencies exist only for src/interop-v1.test.ts, which runs the published 1.7.5 package against this build. The library does not use them.",
"devDependencies": {
"@boneskull/typedoc-plugin-mermaid": "^0.2.0",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@modelcontextprotocol/server": "2.0.0",
"@playwright/test": "1.57.0",
"@types/bun": "^1.3.2",
@ -95,6 +97,7 @@
"electron-to-chromium": "1.5.267",
"esbuild": "^0.25.12",
"express": "^5.1.0",
"ext-apps-v1": "npm:@modelcontextprotocol/ext-apps@1.7.5",
"husky": "^9.1.7",
"nodemon": "^3.1.0",
"playwright": "1.57.0",

379
src/interop-v1.test.ts Normal file
View file

@ -0,0 +1,379 @@
/**
* Cross-version interop against the *published* 1.x package.
*
* `ext-apps-v1` is an npm alias for `@modelcontextprotocol/ext-apps@1.7.5`
* (built on `@modelcontextprotocol/sdk` 1.x). Each pairing wires a real
* `App` to a real `AppBridge` of the other major over an in-memory duplex,
* with the bridge fronting a real MCP `Client`+`McpServer` of its own SDK
* generation, and asserts the observable outcomes. Complements
* `wire-compat.test.ts`, which replays hand-shaped 1.x JSON instead.
*/
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { readFileSync } from "node:fs";
import {
Client as ClientV2,
InMemoryTransport as InMemoryTransportV2,
ProtocolError,
type Transport,
} from "@modelcontextprotocol/client";
import { EmptyResultSchema as EmptyResultSchemaV2 } from "@modelcontextprotocol/core";
import { McpServer as McpServerV2 } from "@modelcontextprotocol/server";
import { Client as ClientV1 } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport as InMemoryTransportV1 } from "@modelcontextprotocol/sdk/inMemory.js";
import { McpServer as McpServerV1 } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Protocol as ProtocolV1 } from "@modelcontextprotocol/sdk/shared/protocol.js";
import {
EmptyResultSchema as EmptyResultSchemaV1,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import { App as AppV1 } from "ext-apps-v1";
import { AppBridge as AppBridgeV1 } from "ext-apps-v1/app-bridge";
import { z } from "zod/v4";
import { App as AppV2 } from "./app.js";
import { AppBridge as AppBridgeV2 } from "./app-bridge.js";
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
/** In-memory duplex. Structurally a `Transport` for both SDK generations. */
function createDuplex(): [Transport, Transport] {
const make = () => {
let closed = false;
const t: Transport & { peer?: typeof t } = {
async start() {},
async send(message) {
// Serialize like a real transport so each side parses plain JSON.
t.peer?.onmessage?.(JSON.parse(JSON.stringify(message)));
},
async close() {
if (closed) return;
closed = true;
t.onclose?.();
await t.peer?.close();
},
};
return t;
};
const [a, b] = [make(), make()];
a.peer = b;
b.peer = a;
return [a, b];
}
/** One SDK generation's MCP pieces, as used by the App side or the host side. */
const sdkV1 = {
Client: ClientV1,
McpServer: McpServerV1,
InMemoryTransport: InMemoryTransportV1,
EmptyResultSchema: EmptyResultSchemaV1,
error: (code: number, message: string): Error => new McpError(code, message),
};
const sdkV2 = {
Client: ClientV2,
McpServer: McpServerV2,
InMemoryTransport: InMemoryTransportV2,
EmptyResultSchema: EmptyResultSchemaV2,
error: (code: number, message: string): Error =>
new ProtocolError(code, message),
};
const pairings = [
{
name: "2.x App with 1.x AppBridge (sdk 1.x host)",
App: AppV2 as any,
AppBridge: AppBridgeV1 as any,
view: sdkV2,
host: sdkV1,
// sdk 1.x reports a Zod params failure as InternalError.
invalidParamsCode: -32603,
// sdk 1.x forwards a handler-thrown -32002 unchanged.
handlerThrown32002Code: -32002,
// McpServer 1.x answers an unknown tool with an isError result.
unknownToolRejects: false,
},
{
name: "1.x App with 2.x AppBridge (sdk 2.x host)",
App: AppV1 as any,
AppBridge: AppBridgeV2 as any,
view: sdkV1,
host: sdkV2,
invalidParamsCode: -32602,
// SDK 2.x never emits -32002 on the wire (see wire-compat.test.ts).
handlerThrown32002Code: -32602,
// McpServer 2.x answers an unknown tool with a JSON-RPC error.
unknownToolRejects: true,
},
];
const hostInfo = { name: "TestHost", version: "9.9.9" };
const appInfo = { name: "TestView", version: "1.2.3" };
const hostCapabilities = {
openLinks: {},
serverTools: {},
serverResources: {},
logging: {},
updateModelContext: {},
};
const hostContext = { theme: "dark", displayMode: "inline", locale: "en-US" };
it("pits 2.x against the published 1.x package, not against itself", () => {
const v1PackageJson = JSON.parse(
readFileSync(
new URL("../../package.json", import.meta.resolve("ext-apps-v1")),
"utf8",
),
);
expect(v1PackageJson).toMatchObject({
name: "@modelcontextprotocol/ext-apps",
version: "1.7.5",
});
// 1.x classes extend the sdk-1 Protocol; 2.x classes do not.
expect(AppV1.prototype).toBeInstanceOf(ProtocolV1);
expect(AppBridgeV1.prototype).toBeInstanceOf(ProtocolV1);
expect(AppV2.prototype).not.toBeInstanceOf(ProtocolV1);
expect(AppBridgeV2.prototype).not.toBeInstanceOf(ProtocolV1);
});
// The tests in each pairing share one connected App/AppBridge fixture and run
// in file order (bun does not randomize); later tests assume earlier ones
// left the pair connected.
describe.each(pairings)("$name", (pairing) => {
const { view, host } = pairing;
let server: any, client: any, bridge: any, app: any;
const events: string[] = [];
const record = (name: string) => (params?: unknown) => {
events.push(`${name}:${JSON.stringify(params ?? null)}`);
};
const drain = () => events.splice(0);
// Captured inside oninitialized: the bridge must have processed
// ui/initialize before ui/notifications/initialized arrives.
let appVersionAtInitialized: unknown = "not-fired";
let hostAbortReason: unknown;
beforeAll(async () => {
server = new host.McpServer({ name: "TestServer", version: "1.0.0" });
server.registerTool(
"echo",
{ inputSchema: z.object({ a: z.number() }) },
async ({ a }: { a: number }) => ({
content: [{ type: "text", text: `echo ${a}` }],
}),
);
server.registerTool("boom", { inputSchema: z.object({}) }, async () => {
throw new Error("tool exploded");
});
server.registerResource(
"res",
"test://res",
{ mimeType: "text/plain" },
async (uri: URL) => ({
contents: [{ uri: uri.href, mimeType: "text/plain", text: "hello" }],
}),
);
const [clientTransport, serverTransport] =
host.InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
client = new host.Client({ name: "HostOuterClient", version: "1.0.0" });
await client.connect(clientTransport);
bridge = new pairing.AppBridge(client, hostInfo, hostCapabilities, {
hostContext,
});
app = new pairing.App(
appInfo,
{ tools: { listChanged: true } },
{
autoResize: false,
},
);
bridge.oninitialized = () => {
appVersionAtInitialized = bridge.getAppVersion();
};
for (const name of ["onsizechange", "onloggingmessage"]) {
bridge[name] = record(`bridge.${name}`);
}
for (const name of ["onmessage", "onupdatemodelcontext"]) {
bridge[name] = async (params: unknown) => {
record(`bridge.${name}`)(params);
return {};
};
}
bridge.onopenlink = (params: { url: string }, extra: any) => {
record("bridge.onopenlink")(params);
if (params.url !== "https://slow") return {};
// Hang until the view cancels; sdk 1.x passes `extra.signal`, 2.x `extra.mcpReq.signal`.
const signal: AbortSignal = extra.signal ?? extra.mcpReq.signal;
return new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => {
hostAbortReason = signal.reason;
reject(new Error("aborted"));
});
});
};
for (const name of [
"ontoolinput",
"ontoolresult",
"ontoolcancelled",
"onhostcontextchanged",
]) {
app[name] = record(`app.${name}`);
}
app.onteardown = async (params: unknown) => {
record("app.onteardown")(params);
return {};
};
});
afterAll(async () => {
await app?.close().catch(() => {});
await bridge?.close().catch(() => {});
await client?.close().catch(() => {});
await server?.close().catch(() => {});
});
it("completes ui/initialize and delivers initialized in order", async () => {
const [appTransport, bridgeTransport] = createDuplex();
// Tap the raw ui/initialize result so an additive key on the 2.x side
// (which the getters below would not surface) still fails here.
let initializeResult: unknown;
const send = bridgeTransport.send.bind(bridgeTransport);
bridgeTransport.send = async (message, options) => {
if ("result" in message && "id" in message && message.id === 0) {
initializeResult = message.result;
}
await send(message, options);
};
await bridge.connect(bridgeTransport);
await app.connect(appTransport);
await flush();
expect(app.getHostContext()).toEqual(hostContext);
expect(app.getHostCapabilities()).toEqual(hostCapabilities);
expect(app.getHostVersion()).toEqual(hostInfo);
expect(bridge.getAppCapabilities()).toEqual({
tools: { listChanged: true },
});
expect(appVersionAtInitialized).toEqual(appInfo);
expect(initializeResult).toEqual({
protocolVersion: "2026-01-26",
hostCapabilities,
hostInfo,
hostContext,
});
});
it("delivers host-to-view notifications", async () => {
bridge.sendToolInput({ arguments: { location: "NYC" } });
bridge.sendToolResult({ content: [{ type: "text", text: "sunny" }] });
bridge.sendToolCancelled({ reason: "user" });
bridge.setHostContext({ theme: "light" });
await flush();
expect(drain()).toEqual([
'app.ontoolinput:{"arguments":{"location":"NYC"}}',
'app.ontoolresult:{"content":[{"type":"text","text":"sunny"}]}',
'app.ontoolcancelled:{"reason":"user"}',
'app.onhostcontextchanged:{"theme":"light"}',
]);
expect(app.getHostContext()).toEqual({ ...hostContext, theme: "light" });
});
it("round-trips view-to-host requests and notifications", async () => {
const message = { role: "user", content: [{ type: "text", text: "hi" }] };
expect(await app.sendMessage(message)).toEqual({});
expect(await app.openLink({ url: "https://example.com" })).toEqual({});
expect(await app.requestDisplayMode({ mode: "fullscreen" })).toEqual({
mode: "inline",
});
bridge.onrequestdisplaymode = async (params: { mode: string }) => params;
expect(await app.requestDisplayMode({ mode: "fullscreen" })).toEqual({
mode: "fullscreen",
});
const context = { content: [{ type: "text", text: "ctx" }] };
expect(await app.updateModelContext(context)).toEqual({});
await app.sendLog({ level: "info", data: { m: "hello" } });
await app.sendSizeChanged({ width: 320, height: 240 });
await flush();
expect(drain()).toEqual([
`bridge.onmessage:${JSON.stringify(message)}`,
'bridge.onopenlink:{"url":"https://example.com"}',
`bridge.onupdatemodelcontext:${JSON.stringify(context)}`,
'bridge.onloggingmessage:{"level":"info","data":{"m":"hello"}}',
'bridge.onsizechange:{"width":320,"height":240}',
]);
});
it("proxies tools/call and resources/read to the real MCP server", async () => {
expect(
await app.callServerTool({ name: "echo", arguments: { a: 1 } }),
).toEqual({ content: [{ type: "text", text: "echo 1" }] });
expect(
await app.callServerTool({ name: "boom", arguments: {} }),
).toMatchObject({
isError: true,
content: [{ type: "text", text: "tool exploded" }],
});
const unknown = app.callServerTool({ name: "nope", arguments: {} });
if (pairing.unknownToolRejects) {
await expect(unknown).rejects.toMatchObject({ code: -32602 });
} else {
expect(await unknown).toMatchObject({ isError: true });
}
expect(await app.readServerResource({ uri: "test://res" })).toEqual({
contents: [{ uri: "test://res", mimeType: "text/plain", text: "hello" }],
});
});
it("propagates JSON-RPC errors with the expected codes", async () => {
const raw = (method: string, params: unknown) =>
app.request({ method, params }, view.EmptyResultSchema);
await expect(raw("ui/does-not-exist", {})).rejects.toMatchObject({
code: -32601,
});
await expect(raw("ui/open-link", { url: 42 })).rejects.toMatchObject({
code: pairing.invalidParamsCode,
});
bridge.onreadresource = async () => {
throw host.error(-32002, "Resource not found (host-thrown -32002)");
};
const notFound = app.readServerResource({ uri: "test://missing" });
await expect(notFound).rejects.toMatchObject({
code: pairing.handlerThrown32002Code,
message: expect.stringContaining(
"Resource not found (host-thrown -32002)",
),
});
});
it("cancels an in-flight request via AbortSignal on both ends", async () => {
const controller = new AbortController();
const pending = app.openLink(
{ url: "https://slow" },
{ signal: controller.signal },
);
await flush();
controller.abort("user-cancelled");
await expect(pending).rejects.toMatchObject({
message: expect.stringContaining("user-cancelled"),
});
await flush();
expect(String(hostAbortReason)).toContain("user-cancelled");
drain();
});
it("tears down, then closes both ends", async () => {
expect(await bridge.teardownResource({})).toEqual({});
expect(drain()).toEqual(["app.onteardown:{}"]);
const closed: string[] = [];
app.onclose = () => closed.push("app");
bridge.onclose = () => closed.push("bridge");
await app.close();
await flush();
expect(closed.sort()).toEqual(["app", "bridge"]);
await expect(app.openLink({ url: "https://after-close" })).rejects.toThrow(
"Not connected",
);
});
});