@runtypelabs/persona 3.21.3 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -0
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
- package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +50 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +474 -6
- package/dist/index.d.ts +474 -6
- package/dist/index.global.js +98 -88
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +48 -41
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +1875 -0
- package/dist/smart-dom-reader.d.cts +4521 -0
- package/dist/smart-dom-reader.d.ts +4521 -0
- package/dist/smart-dom-reader.js +1848 -0
- package/dist/theme-editor.cjs +2281 -84
- package/dist/theme-editor.d.cts +348 -1
- package/dist/theme-editor.d.ts +348 -1
- package/dist/theme-editor.js +2260 -78
- package/package.json +9 -2
- package/src/client.test.ts +165 -0
- package/src/client.ts +144 -23
- package/src/index.ts +26 -0
- package/src/session.test.ts +258 -0
- package/src/session.ts +886 -30
- package/src/session.webmcp.test.ts +815 -0
- package/src/smart-dom-reader.test.ts +135 -0
- package/src/smart-dom-reader.ts +135 -0
- package/src/theme-editor/color-utils.test.ts +59 -0
- package/src/theme-editor/color-utils.ts +38 -2
- package/src/theme-editor/index.ts +35 -0
- package/src/theme-editor/webmcp/coerce.test.ts +86 -0
- package/src/theme-editor/webmcp/coerce.ts +286 -0
- package/src/theme-editor/webmcp/index.ts +45 -0
- package/src/theme-editor/webmcp/summary.ts +324 -0
- package/src/theme-editor/webmcp/tools.test.ts +205 -0
- package/src/theme-editor/webmcp/tools.ts +795 -0
- package/src/theme-editor/webmcp/types.ts +87 -0
- package/src/types.ts +186 -0
- package/src/ui.composer-keyboard.test.ts +229 -0
- package/src/ui.ts +127 -5
- package/src/utils/composer-history.test.ts +128 -0
- package/src/utils/composer-history.ts +113 -0
- package/src/utils/message-fingerprint.test.ts +20 -0
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/smart-dom-adapter.test.ts +257 -0
- package/src/utils/smart-dom-adapter.ts +217 -0
- package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
- package/src/vendor/smart-dom-reader/README.md +61 -0
- package/src/vendor/smart-dom-reader/index.d.ts +476 -0
- package/src/vendor/smart-dom-reader/index.js +1618 -0
- package/src/webmcp-bridge.test.ts +429 -0
- package/src/webmcp-bridge.ts +547 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AgentWidgetWebMcpConfig,
|
|
5
|
+
WebMcpConfirmHandler,
|
|
6
|
+
WebMcpToolResult,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Mock the strict @mcp-b/webmcp-polyfill. The bridge dynamically imports it and
|
|
11
|
+
// calls `initializeWebMCPPolyfill()` (idempotent install of document.modelContext).
|
|
12
|
+
// We stub the install as a no-op and provide `document.modelContext` ourselves,
|
|
13
|
+
// modeling the strict producer-preview surface the bridge consumes:
|
|
14
|
+
// - getTools(): async; inputSchema is a JSON string; NO annotations
|
|
15
|
+
// - executeTool(info, argsJson, { signal }): async; validates+runs execute(),
|
|
16
|
+
// returns JSON.stringify(rawResult) or null; honors the abort signal.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const polyfillMock = { initThrows: false };
|
|
20
|
+
|
|
21
|
+
vi.mock("@mcp-b/webmcp-polyfill", () => ({
|
|
22
|
+
initializeWebMCPPolyfill: vi.fn(() => {
|
|
23
|
+
if (polyfillMock.initThrows) throw new Error("init boom");
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// Import AFTER vi.mock so the bridge's dynamic import resolves to the mock.
|
|
28
|
+
import {
|
|
29
|
+
WebMcpBridge,
|
|
30
|
+
isWebMcpToolName,
|
|
31
|
+
stripWebMcpPrefix,
|
|
32
|
+
} from "./webmcp-bridge";
|
|
33
|
+
|
|
34
|
+
type MockClient = { requestUserInteraction: (cb: () => unknown) => Promise<unknown> };
|
|
35
|
+
|
|
36
|
+
type MockTool = {
|
|
37
|
+
name: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
inputSchema?: object;
|
|
40
|
+
execute: (args: Record<string, unknown>, client: MockClient) => unknown;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const registry: { tools: MockTool[] } = { tools: [] };
|
|
44
|
+
|
|
45
|
+
/** A fake `document.modelContext` exposing the strict consumer surface. */
|
|
46
|
+
const makeModelContext = () => ({
|
|
47
|
+
async getTools() {
|
|
48
|
+
return registry.tools.map((t) => ({
|
|
49
|
+
name: t.name,
|
|
50
|
+
description: t.description ?? `mock ${t.name}`,
|
|
51
|
+
inputSchema: JSON.stringify(t.inputSchema ?? { type: "object" }),
|
|
52
|
+
}));
|
|
53
|
+
},
|
|
54
|
+
async executeTool(
|
|
55
|
+
info: { name: string },
|
|
56
|
+
inputArgsJson: string,
|
|
57
|
+
options?: { signal?: AbortSignal },
|
|
58
|
+
): Promise<string | null> {
|
|
59
|
+
if (options?.signal?.aborted) throw new Error("Tool execution was cancelled");
|
|
60
|
+
const tool = registry.tools.find((t) => t.name === info.name);
|
|
61
|
+
if (!tool) throw new Error(`Tool not found: ${info.name}`);
|
|
62
|
+
const args = inputArgsJson ? JSON.parse(inputArgsJson) : {};
|
|
63
|
+
// The polyfill owns this client; `requestUserInteraction` is a pass-through.
|
|
64
|
+
const client: MockClient = {
|
|
65
|
+
requestUserInteraction: async (cb) => cb(),
|
|
66
|
+
};
|
|
67
|
+
const execPromise = Promise.resolve(tool.execute(args, client));
|
|
68
|
+
const raced = options?.signal
|
|
69
|
+
? Promise.race<unknown>([
|
|
70
|
+
execPromise,
|
|
71
|
+
new Promise<never>((_, reject) => {
|
|
72
|
+
const sig = options.signal!;
|
|
73
|
+
if (sig.aborted) reject(new Error("Tool execution was cancelled"));
|
|
74
|
+
else
|
|
75
|
+
sig.addEventListener(
|
|
76
|
+
"abort",
|
|
77
|
+
() => reject(new Error("Tool execution was cancelled")),
|
|
78
|
+
{ once: true },
|
|
79
|
+
);
|
|
80
|
+
}),
|
|
81
|
+
])
|
|
82
|
+
: execPromise;
|
|
83
|
+
const raw = await raced;
|
|
84
|
+
return raw === undefined ? null : JSON.stringify(raw);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const fakeTool = (
|
|
89
|
+
overrides: Partial<MockTool> & { name: string },
|
|
90
|
+
): MockTool => ({
|
|
91
|
+
description: `mock ${overrides.name}`,
|
|
92
|
+
execute: () => "ok",
|
|
93
|
+
...overrides,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const allowAll: WebMcpConfirmHandler = vi.fn(async () => true);
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
vi.clearAllMocks();
|
|
100
|
+
polyfillMock.initThrows = false;
|
|
101
|
+
registry.tools = [];
|
|
102
|
+
// location is read by snapshotForDispatch; document.modelContext is the
|
|
103
|
+
// consumer surface. Both are absent in the Node test environment.
|
|
104
|
+
vi.stubGlobal("location", { origin: "https://example.test" });
|
|
105
|
+
vi.stubGlobal("document", { modelContext: makeModelContext() });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
vi.unstubAllGlobals();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("stripWebMcpPrefix", () => {
|
|
113
|
+
it("strips a leading 'webmcp:' prefix", () => {
|
|
114
|
+
expect(stripWebMcpPrefix("webmcp:add_to_cart")).toBe("add_to_cart");
|
|
115
|
+
});
|
|
116
|
+
it("leaves names without the prefix untouched", () => {
|
|
117
|
+
expect(stripWebMcpPrefix("add_to_cart")).toBe("add_to_cart");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("isWebMcpToolName", () => {
|
|
122
|
+
it("returns true for prefixed names", () => {
|
|
123
|
+
expect(isWebMcpToolName("webmcp:search")).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
it("returns false otherwise", () => {
|
|
126
|
+
expect(isWebMcpToolName("builtin:search")).toBe(false);
|
|
127
|
+
expect(isWebMcpToolName("search")).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("WebMcpBridge.snapshotForDispatch", () => {
|
|
132
|
+
it("returns empty when config.webmcp.enabled is not set", async () => {
|
|
133
|
+
const bridge = new WebMcpBridge({} as AgentWidgetWebMcpConfig);
|
|
134
|
+
expect(bridge.isOperational()).toBe(false);
|
|
135
|
+
expect(await bridge.snapshotForDispatch()).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns empty when document.modelContext is absent (no polyfill, no native)", async () => {
|
|
139
|
+
vi.stubGlobal("document", {});
|
|
140
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
141
|
+
expect(await bridge.snapshotForDispatch()).toEqual([]);
|
|
142
|
+
expect(bridge.isOperational()).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns empty when initializeWebMCPPolyfill() throws", async () => {
|
|
146
|
+
polyfillMock.initThrows = true;
|
|
147
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
148
|
+
expect(await bridge.snapshotForDispatch()).toEqual([]);
|
|
149
|
+
expect(bridge.isOperational()).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("ships only the JSON-serializable surface (name, description, schema, origin)", async () => {
|
|
153
|
+
registry.tools = [
|
|
154
|
+
fakeTool({
|
|
155
|
+
name: "search",
|
|
156
|
+
description: "search the shop",
|
|
157
|
+
inputSchema: { type: "object", properties: { q: { type: "string" } } },
|
|
158
|
+
}),
|
|
159
|
+
];
|
|
160
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
161
|
+
const snap = await bridge.snapshotForDispatch();
|
|
162
|
+
expect(snap).toHaveLength(1);
|
|
163
|
+
const tool = snap[0]!;
|
|
164
|
+
expect(tool.name).toBe("search");
|
|
165
|
+
expect(tool.description).toBe("search the shop");
|
|
166
|
+
expect(tool.parametersSchema).toEqual({
|
|
167
|
+
type: "object",
|
|
168
|
+
properties: { q: { type: "string" } },
|
|
169
|
+
});
|
|
170
|
+
expect(tool.origin).toBe("webmcp");
|
|
171
|
+
expect(tool.pageOrigin).toBe("https://example.test");
|
|
172
|
+
expect((tool as unknown as { execute?: unknown }).execute).toBeUndefined();
|
|
173
|
+
// Now that the registry was read, the bridge reports operational.
|
|
174
|
+
expect(bridge.isOperational()).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("applies client-side allowlist glob (`search_*`)", async () => {
|
|
178
|
+
registry.tools = [
|
|
179
|
+
fakeTool({ name: "search_products" }),
|
|
180
|
+
fakeTool({ name: "add_to_cart" }),
|
|
181
|
+
];
|
|
182
|
+
const bridge = new WebMcpBridge({
|
|
183
|
+
enabled: true,
|
|
184
|
+
allowlist: ["search_*"],
|
|
185
|
+
});
|
|
186
|
+
const snap = await bridge.snapshotForDispatch();
|
|
187
|
+
expect(snap.map((t) => t.name)).toEqual(["search_products"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("respects '*' as match-all", async () => {
|
|
191
|
+
registry.tools = [fakeTool({ name: "foo" }), fakeTool({ name: "bar" })];
|
|
192
|
+
const bridge = new WebMcpBridge({ enabled: true, allowlist: ["*"] });
|
|
193
|
+
expect((await bridge.snapshotForDispatch()).map((t) => t.name)).toEqual([
|
|
194
|
+
"foo",
|
|
195
|
+
"bar",
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("WebMcpBridge.executeToolCall", () => {
|
|
201
|
+
it("returns isError when WebMCP is not enabled", async () => {
|
|
202
|
+
const bridge = new WebMcpBridge({} as AgentWidgetWebMcpConfig);
|
|
203
|
+
const r = await bridge.executeToolCall("webmcp:search", {});
|
|
204
|
+
expect(r.isError).toBe(true);
|
|
205
|
+
expect(r.content[0]?.type).toBe("text");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("returns isError when document.modelContext is absent", async () => {
|
|
209
|
+
vi.stubGlobal("document", {});
|
|
210
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
211
|
+
const r = await bridge.executeToolCall("webmcp:search", {});
|
|
212
|
+
expect(r.isError).toBe(true);
|
|
213
|
+
expect((r.content[0] as { text: string }).text).toMatch(/not operational/i);
|
|
214
|
+
expect((r.content[0] as { text: string }).text).toMatch(/not available/i);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("warns once and degrades cleanly when document.modelContext is present but incompatible", async () => {
|
|
218
|
+
// A different / older WebMCP polyfill (or divergent native draft) squats
|
|
219
|
+
// document.modelContext without the strict getTools()/executeTool() surface.
|
|
220
|
+
// @mcp-b's initializeWebMCPPolyfill correctly declines to overwrite it, so
|
|
221
|
+
// Persona must (a) report non-operational, (b) surface an actionable error
|
|
222
|
+
// distinct from "not available", and (c) warn exactly once.
|
|
223
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
224
|
+
vi.stubGlobal("document", {
|
|
225
|
+
modelContext: { registerTool: () => undefined }, // no getTools/executeTool
|
|
226
|
+
});
|
|
227
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
228
|
+
|
|
229
|
+
expect(await bridge.snapshotForDispatch()).toEqual([]);
|
|
230
|
+
expect(bridge.isOperational()).toBe(false);
|
|
231
|
+
|
|
232
|
+
const r = await bridge.executeToolCall("webmcp:search", {});
|
|
233
|
+
expect(r.isError).toBe(true);
|
|
234
|
+
expect((r.content[0] as { text: string }).text).toMatch(/present but/i);
|
|
235
|
+
|
|
236
|
+
// Warned about the incompatible context, exactly once despite multiple hits.
|
|
237
|
+
const incompatWarnings = warnSpy.mock.calls.filter(([msg]) =>
|
|
238
|
+
String(msg).includes("does not expose getTools()/executeTool()"),
|
|
239
|
+
);
|
|
240
|
+
expect(incompatWarnings).toHaveLength(1);
|
|
241
|
+
warnSpy.mockRestore();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("strips the webmcp: prefix before registry lookup and forwards args", async () => {
|
|
245
|
+
const execute = vi.fn(() => ({ matches: 3 }));
|
|
246
|
+
registry.tools = [fakeTool({ name: "search", execute })];
|
|
247
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
248
|
+
const r = await bridge.executeToolCall("webmcp:search", { q: "shoes" });
|
|
249
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
250
|
+
expect(execute).toHaveBeenCalledWith({ q: "shoes" }, expect.anything());
|
|
251
|
+
expect(r.isError).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("returns isError when the tool is not in the registry (unmount race)", async () => {
|
|
255
|
+
registry.tools = [fakeTool({ name: "search" })];
|
|
256
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
257
|
+
const r = await bridge.executeToolCall("webmcp:add_to_cart", {});
|
|
258
|
+
expect(r.isError).toBe(true);
|
|
259
|
+
expect((r.content[0] as { text: string }).text).toMatch(/not registered/);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("normalizes a string return into a single text content block", async () => {
|
|
263
|
+
registry.tools = [fakeTool({ name: "ping", execute: () => "pong" })];
|
|
264
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
265
|
+
const r = await bridge.executeToolCall("webmcp:ping", {});
|
|
266
|
+
expect(r).toEqual({ content: [{ type: "text", text: "pong" }] });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("normalizes an object return by JSON-stringifying it", async () => {
|
|
270
|
+
registry.tools = [
|
|
271
|
+
fakeTool({ name: "lookup", execute: () => ({ found: true, n: 7 }) }),
|
|
272
|
+
];
|
|
273
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
274
|
+
const r = await bridge.executeToolCall("webmcp:lookup", {});
|
|
275
|
+
expect(r.content[0]).toEqual({
|
|
276
|
+
type: "text",
|
|
277
|
+
text: JSON.stringify({ found: true, n: 7 }),
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("passes already-MCP-shaped returns through unchanged", async () => {
|
|
282
|
+
const shaped: WebMcpToolResult = {
|
|
283
|
+
content: [
|
|
284
|
+
{ type: "text", text: "hi" },
|
|
285
|
+
{ type: "image", url: "x" },
|
|
286
|
+
],
|
|
287
|
+
};
|
|
288
|
+
registry.tools = [fakeTool({ name: "render", execute: () => shaped })];
|
|
289
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
290
|
+
const r = await bridge.executeToolCall("webmcp:render", {});
|
|
291
|
+
expect(r).toEqual(shaped);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("normalizes an undefined return into an empty text block", async () => {
|
|
295
|
+
registry.tools = [fakeTool({ name: "act", execute: () => undefined })];
|
|
296
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
297
|
+
const r = await bridge.executeToolCall("webmcp:act", {});
|
|
298
|
+
expect(r).toEqual({ content: [{ type: "text", text: "" }] });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("runs a tool's client.requestUserInteraction callback without a second confirm", async () => {
|
|
302
|
+
const confirmSpy = vi.fn(async () => true);
|
|
303
|
+
registry.tools = [
|
|
304
|
+
fakeTool({
|
|
305
|
+
name: "sensitive",
|
|
306
|
+
execute: async (_args, client) => {
|
|
307
|
+
const ack = await client.requestUserInteraction(async () => "ok!");
|
|
308
|
+
return { ack };
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
];
|
|
312
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: confirmSpy });
|
|
313
|
+
const r = await bridge.executeToolCall("webmcp:sensitive", {});
|
|
314
|
+
// Only the single outer gate fires — the polyfill owns the in-tool callback.
|
|
315
|
+
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
|
316
|
+
expect(r.isError).toBeUndefined();
|
|
317
|
+
expect(r.content[0]).toEqual({
|
|
318
|
+
type: "text",
|
|
319
|
+
text: JSON.stringify({ ack: "ok!" }),
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("returns isError when the user declines the confirm gate", async () => {
|
|
324
|
+
const decline: WebMcpConfirmHandler = vi.fn(async () => false);
|
|
325
|
+
const executeSpy = vi.fn(() => "should not run");
|
|
326
|
+
registry.tools = [fakeTool({ name: "checkout", execute: executeSpy })];
|
|
327
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: decline });
|
|
328
|
+
const r = await bridge.executeToolCall("webmcp:checkout", { sku: "x" });
|
|
329
|
+
expect(r.isError).toBe(true);
|
|
330
|
+
expect((r.content[0] as { text: string }).text).toMatch(/declined/);
|
|
331
|
+
expect(decline).toHaveBeenCalledTimes(1);
|
|
332
|
+
expect(executeSpy).not.toHaveBeenCalled();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("translates a thrown execute() into an isError result (no rethrow)", async () => {
|
|
336
|
+
registry.tools = [
|
|
337
|
+
fakeTool({
|
|
338
|
+
name: "boom",
|
|
339
|
+
execute: () => {
|
|
340
|
+
throw new Error("network down");
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
];
|
|
344
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
345
|
+
const r = await bridge.executeToolCall("webmcp:boom", {});
|
|
346
|
+
expect(r.isError).toBe(true);
|
|
347
|
+
expect((r.content[0] as { text: string }).text).toBe("network down");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("times out an execute() that exceeds the 30s budget", async () => {
|
|
351
|
+
vi.useFakeTimers();
|
|
352
|
+
try {
|
|
353
|
+
registry.tools = [
|
|
354
|
+
fakeTool({ name: "slow", execute: () => new Promise(() => undefined) }),
|
|
355
|
+
];
|
|
356
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
357
|
+
const pending = bridge.executeToolCall("webmcp:slow", {});
|
|
358
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
359
|
+
const r = await pending;
|
|
360
|
+
expect(r.isError).toBe(true);
|
|
361
|
+
expect((r.content[0] as { text: string }).text).toMatch(/timed out/);
|
|
362
|
+
} finally {
|
|
363
|
+
vi.useRealTimers();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("bails before rendering the confirm bubble when signal is already aborted", async () => {
|
|
368
|
+
// A late approval after cancel() must not fire a host-page side effect. The
|
|
369
|
+
// bridge checks the signal BEFORE rendering the confirm.
|
|
370
|
+
const confirmSpy = vi.fn(async () => true);
|
|
371
|
+
const executeSpy = vi.fn(() => "should not run");
|
|
372
|
+
registry.tools = [fakeTool({ name: "checkout", execute: executeSpy })];
|
|
373
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: confirmSpy });
|
|
374
|
+
const controller = new AbortController();
|
|
375
|
+
controller.abort();
|
|
376
|
+
const r = await bridge.executeToolCall(
|
|
377
|
+
"webmcp:checkout",
|
|
378
|
+
{},
|
|
379
|
+
controller.signal,
|
|
380
|
+
);
|
|
381
|
+
expect(r.isError).toBe(true);
|
|
382
|
+
expect((r.content[0] as { text: string }).text).toMatch(/abort/i);
|
|
383
|
+
expect(confirmSpy).not.toHaveBeenCalled();
|
|
384
|
+
expect(executeSpy).not.toHaveBeenCalled();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("aborts a stuck execute() when the signal fires mid-flight", async () => {
|
|
388
|
+
let resolveStuck: ((v: string) => void) | undefined;
|
|
389
|
+
const stuck = new Promise<string>((resolve) => {
|
|
390
|
+
resolveStuck = resolve;
|
|
391
|
+
});
|
|
392
|
+
const executeSpy = vi.fn(() => stuck);
|
|
393
|
+
registry.tools = [fakeTool({ name: "slow", execute: executeSpy })];
|
|
394
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
395
|
+
const controller = new AbortController();
|
|
396
|
+
const pending = bridge.executeToolCall(
|
|
397
|
+
"webmcp:slow",
|
|
398
|
+
{},
|
|
399
|
+
controller.signal,
|
|
400
|
+
);
|
|
401
|
+
// Wait until execute() has actually started, then cancel.
|
|
402
|
+
await vi.waitFor(() => expect(executeSpy).toHaveBeenCalledTimes(1));
|
|
403
|
+
controller.abort();
|
|
404
|
+
const r = await pending;
|
|
405
|
+
expect(r.isError).toBe(true);
|
|
406
|
+
expect((r.content[0] as { text: string }).text).toMatch(/abort/i);
|
|
407
|
+
// Late resolve from the page side — must not poison anything.
|
|
408
|
+
resolveStuck?.("late");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("rejects a webmcp call for a tool excluded by the client allowlist", async () => {
|
|
412
|
+
// snapshotForDispatch filters by allowlist for the wire surface, but
|
|
413
|
+
// executeToolCall must also re-check it — defense-in-depth alongside the
|
|
414
|
+
// server-side check.
|
|
415
|
+
const executeSpy = vi.fn(() => "should not run");
|
|
416
|
+
registry.tools = [
|
|
417
|
+
fakeTool({ name: "secret_admin_action", execute: executeSpy }),
|
|
418
|
+
];
|
|
419
|
+
const bridge = new WebMcpBridge({
|
|
420
|
+
enabled: true,
|
|
421
|
+
onConfirm: allowAll,
|
|
422
|
+
allowlist: ["search_*"],
|
|
423
|
+
});
|
|
424
|
+
const r = await bridge.executeToolCall("webmcp:secret_admin_action", {});
|
|
425
|
+
expect(r.isError).toBe(true);
|
|
426
|
+
expect((r.content[0] as { text: string }).text).toMatch(/allowlist/i);
|
|
427
|
+
expect(executeSpy).not.toHaveBeenCalled();
|
|
428
|
+
});
|
|
429
|
+
});
|