@aprovan/mcp-app-server 0.1.0-dev.c1ac1dc
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/.turbo/turbo-build.log +20 -0
- package/E2E_TESTING.md +198 -0
- package/LICENSE +373 -0
- package/README.md +134 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.js +1592 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +1841 -0
- package/dist/server.js.map +1 -0
- package/package.json +52 -0
- package/src/__tests__/cache.test.ts +119 -0
- package/src/__tests__/cdn-plugin.test.ts +86 -0
- package/src/__tests__/compile.test.ts +93 -0
- package/src/__tests__/e2e-pipeline.test.ts +417 -0
- package/src/__tests__/live-update.test.ts +158 -0
- package/src/__tests__/local-backend.test.ts +100 -0
- package/src/__tests__/memory-backend.ts +144 -0
- package/src/__tests__/registry-backend.test.ts +256 -0
- package/src/__tests__/services.test.ts +153 -0
- package/src/__tests__/shim.test.ts +132 -0
- package/src/__tests__/widget-store.test.ts +183 -0
- package/src/compiler/cache.ts +68 -0
- package/src/compiler/cdn-plugin.ts +197 -0
- package/src/compiler/compile.ts +306 -0
- package/src/compiler/index.ts +3 -0
- package/src/hello-world.html +79 -0
- package/src/html.d.ts +4 -0
- package/src/index.ts +641 -0
- package/src/live-update.ts +149 -0
- package/src/reference-widgets/live-dashboard.ts +162 -0
- package/src/registry-backend.ts +304 -0
- package/src/server.ts +178 -0
- package/src/services.ts +251 -0
- package/src/shim.ts +247 -0
- package/src/widget-store/index.ts +10 -0
- package/src/widget-store/local-backend.ts +77 -0
- package/src/widget-store/store.ts +198 -0
- package/src/widget-store/types.ts +50 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +14 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createProjectFromFiles } from "@aprovan/patchwork-compiler";
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { clear } from "../compiler/cache.js";
|
|
4
|
+
import { compileWidget, cacheHas } from "../compiler/compile.js";
|
|
5
|
+
import type { Manifest } from "@aprovan/patchwork-compiler";
|
|
6
|
+
|
|
7
|
+
const TEST_MANIFEST: Manifest = {
|
|
8
|
+
name: "integration-test",
|
|
9
|
+
version: "0.1.0",
|
|
10
|
+
platform: "browser",
|
|
11
|
+
image: "@aprovan/patchwork-image-shadcn",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const SIMPLE_WIDGET = `export default function Widget() {
|
|
15
|
+
return <div className="p-4 bg-blue-100">Hello Widget</div>;
|
|
16
|
+
}`;
|
|
17
|
+
|
|
18
|
+
describe("compile integration", () => {
|
|
19
|
+
it("compiles a simple widget and returns HTML", async () => {
|
|
20
|
+
const result = await compileWidget(SIMPLE_WIDGET, TEST_MANIFEST);
|
|
21
|
+
|
|
22
|
+
expect(result.html).toContain("<!DOCTYPE html>");
|
|
23
|
+
expect(result.html).toContain("id=\"root\"");
|
|
24
|
+
expect(result.hash).toBeTruthy();
|
|
25
|
+
expect(result.resourceUri).toMatch(/^ui:\/\/widget\//);
|
|
26
|
+
}, 60000);
|
|
27
|
+
|
|
28
|
+
it("caches compiled output", async () => {
|
|
29
|
+
clear();
|
|
30
|
+
const result1 = await compileWidget(SIMPLE_WIDGET, TEST_MANIFEST);
|
|
31
|
+
expect(cacheHas(result1.hash)).toBe(true);
|
|
32
|
+
|
|
33
|
+
const result2 = await compileWidget(SIMPLE_WIDGET, TEST_MANIFEST);
|
|
34
|
+
expect(result2.hash).toBe(result1.hash);
|
|
35
|
+
expect(result2.html).toBe(result1.html);
|
|
36
|
+
expect(result2.resourceUri).toBe(result1.resourceUri);
|
|
37
|
+
}, 60000);
|
|
38
|
+
|
|
39
|
+
it("includes CDN preload scripts in output HTML", async () => {
|
|
40
|
+
const result = await compileWidget(SIMPLE_WIDGET, TEST_MANIFEST);
|
|
41
|
+
expect(result.html).toContain("esm.sh/react");
|
|
42
|
+
}, 60000);
|
|
43
|
+
|
|
44
|
+
it("includes Tailwind CDN in output HTML", async () => {
|
|
45
|
+
const result = await compileWidget(SIMPLE_WIDGET, TEST_MANIFEST);
|
|
46
|
+
expect(result.html).toContain("tailwindcss");
|
|
47
|
+
}, 60000);
|
|
48
|
+
|
|
49
|
+
it("includes shadcn CSS variables in output HTML", async () => {
|
|
50
|
+
const result = await compileWidget(SIMPLE_WIDGET, TEST_MANIFEST);
|
|
51
|
+
expect(result.html).toContain("--background");
|
|
52
|
+
expect(result.html).toContain("--foreground");
|
|
53
|
+
}, 60000);
|
|
54
|
+
|
|
55
|
+
it("compiles a multi-file VirtualProject", async () => {
|
|
56
|
+
const project = createProjectFromFiles([
|
|
57
|
+
{ path: "main.tsx", content: 'import { Greeting } from "./greeting"; export default function Widget() { return <Greeting name="World" />; }' },
|
|
58
|
+
{ path: "greeting.tsx", content: 'export function Greeting({ name }: { name: string }) { return <div className="p-2">Hello, {name}!</div>; }' },
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const result = await compileWidget(project, TEST_MANIFEST);
|
|
62
|
+
expect(result.html).toContain("<!DOCTYPE html>");
|
|
63
|
+
expect(result.hash).toBeTruthy();
|
|
64
|
+
}, 60000);
|
|
65
|
+
|
|
66
|
+
it("injects service shim when services option is provided", async () => {
|
|
67
|
+
const result = await compileWidget(SIMPLE_WIDGET, TEST_MANIFEST, {
|
|
68
|
+
services: ["weather", "stripe"],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.html).toContain("<!DOCTYPE html>");
|
|
72
|
+
expect(result.html).toContain("import { App } from");
|
|
73
|
+
expect(result.html).toContain("esm.sh/@modelcontextprotocol/ext-apps");
|
|
74
|
+
expect(result.html).toContain("weather");
|
|
75
|
+
expect(result.html).toContain("stripe");
|
|
76
|
+
expect(result.html).toContain("__patchwork_createNamespaceProxy");
|
|
77
|
+
expect(result.html).toContain("callServerTool");
|
|
78
|
+
}, 60000);
|
|
79
|
+
|
|
80
|
+
it("does not inject shim when services is empty", async () => {
|
|
81
|
+
const result = await compileWidget(SIMPLE_WIDGET, TEST_MANIFEST, {
|
|
82
|
+
services: [],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(result.html).not.toContain("__patchwork_createNamespaceProxy");
|
|
86
|
+
}, 60000);
|
|
87
|
+
|
|
88
|
+
it("does not inject shim when services option is not provided", async () => {
|
|
89
|
+
const result = await compileWidget(SIMPLE_WIDGET, TEST_MANIFEST);
|
|
90
|
+
|
|
91
|
+
expect(result.html).not.toContain("__patchwork_createNamespaceProxy");
|
|
92
|
+
}, 60000);
|
|
93
|
+
});
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { createProjectFromFiles, type Manifest } from "@aprovan/patchwork-compiler";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
4
|
+
import { clear } from "../compiler/cache.js";
|
|
5
|
+
import { compileWidget, cacheHas } from "../compiler/compile.js";
|
|
6
|
+
import {
|
|
7
|
+
appendEvent,
|
|
8
|
+
getEvents,
|
|
9
|
+
pushStreamUpdate,
|
|
10
|
+
currentSeq,
|
|
11
|
+
registerSession,
|
|
12
|
+
subscribeSession,
|
|
13
|
+
_resetForTests,
|
|
14
|
+
} from "../live-update.js";
|
|
15
|
+
import {
|
|
16
|
+
REFERENCE_WIDGET_FILES,
|
|
17
|
+
REFERENCE_WIDGET_MANIFEST,
|
|
18
|
+
} from "../reference-widgets/live-dashboard.js";
|
|
19
|
+
import { ServiceBridge, type ServiceBackend, type ServiceToolInfo } from "../services.js";
|
|
20
|
+
import { generateServiceShim, generateLiveUpdateShim } from "../shim.js";
|
|
21
|
+
import { WidgetStore, resetWidgetStore } from "../widget-store/store.js";
|
|
22
|
+
import { MemoryBackend } from "./memory-backend.js";
|
|
23
|
+
|
|
24
|
+
const DASHBOARD_MANIFEST: Manifest = REFERENCE_WIDGET_MANIFEST;
|
|
25
|
+
|
|
26
|
+
function makeMockServer(notificationFn = vi.fn().mockResolvedValue(undefined)) {
|
|
27
|
+
return {
|
|
28
|
+
server: { notification: notificationFn },
|
|
29
|
+
} as unknown as McpServer;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const mockBackend: ServiceBackend = {
|
|
33
|
+
call: vi.fn(async (_ns: string, _proc: string, args: unknown[]) => {
|
|
34
|
+
const a = args[0] as Record<string, unknown>;
|
|
35
|
+
if (_ns === "weather" && _proc === "get_forecast") {
|
|
36
|
+
return {
|
|
37
|
+
location: a?.["location"] ?? "unknown",
|
|
38
|
+
temperature: 22,
|
|
39
|
+
conditions: "partly cloudy",
|
|
40
|
+
forecast: [{ day: "today", high: 24, low: 18 }],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { result: "ok" };
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const mockTools: ServiceToolInfo[] = [
|
|
48
|
+
{
|
|
49
|
+
name: "weather.get_forecast",
|
|
50
|
+
namespace: "weather",
|
|
51
|
+
procedure: "get_forecast",
|
|
52
|
+
description: "Get weather forecast for a location",
|
|
53
|
+
parameters: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
location: { type: "string", description: "Location name" },
|
|
57
|
+
},
|
|
58
|
+
required: ["location"],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
describe("E2E: compile → VFS store → render pipeline", () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
clear();
|
|
66
|
+
resetWidgetStore();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("compiles the reference dashboard widget and persists to VFS", async () => {
|
|
70
|
+
const project = createProjectFromFiles(REFERENCE_WIDGET_FILES);
|
|
71
|
+
const store = new WidgetStore({ backend: new MemoryBackend() });
|
|
72
|
+
|
|
73
|
+
const result = await compileWidget(project, DASHBOARD_MANIFEST, {
|
|
74
|
+
services: ["weather"],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(result.html).toContain("<!DOCTYPE html>");
|
|
78
|
+
expect(result.html).toContain("id=\"root\"");
|
|
79
|
+
expect(result.hash).toBeTruthy();
|
|
80
|
+
expect(result.resourceUri).toMatch(/^ui:\/\/widget\//);
|
|
81
|
+
|
|
82
|
+
await store.saveWidget(result.hash, result.html, DASHBOARD_MANIFEST);
|
|
83
|
+
|
|
84
|
+
const stored = await store.getWidget(DASHBOARD_MANIFEST.name, result.hash);
|
|
85
|
+
expect(stored).not.toBeNull();
|
|
86
|
+
expect(stored!.html).toContain("<!DOCTYPE html>");
|
|
87
|
+
expect(stored!.manifest.name).toBe("live-dashboard");
|
|
88
|
+
expect(stored!.manifest.services).toEqual(["weather"]);
|
|
89
|
+
expect(stored!.resourceUri).toMatch(/^ui:\/\/widgets\//);
|
|
90
|
+
|
|
91
|
+
const allWidgets = await store.listWidgets();
|
|
92
|
+
expect(allWidgets).toHaveLength(1);
|
|
93
|
+
expect(allWidgets[0]!.name).toBe("live-dashboard");
|
|
94
|
+
}, 60000);
|
|
95
|
+
|
|
96
|
+
it("compiled HTML contains live-update shim (window.patchwork)", async () => {
|
|
97
|
+
const project = createProjectFromFiles(REFERENCE_WIDGET_FILES);
|
|
98
|
+
const result = await compileWidget(project, DASHBOARD_MANIFEST, {
|
|
99
|
+
services: ["weather"],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.html).toContain("window.patchwork");
|
|
103
|
+
expect(result.html).toContain("subscribe:");
|
|
104
|
+
expect(result.html).toContain("updateContext:");
|
|
105
|
+
expect(result.html).toContain("fireEvent:");
|
|
106
|
+
expect(result.html).toContain("poll_updates");
|
|
107
|
+
expect(result.html).toContain("subscribe_stream");
|
|
108
|
+
}, 60000);
|
|
109
|
+
|
|
110
|
+
it("compiled HTML contains service proxy shim for weather namespace", async () => {
|
|
111
|
+
const project = createProjectFromFiles(REFERENCE_WIDGET_FILES);
|
|
112
|
+
const result = await compileWidget(project, DASHBOARD_MANIFEST, {
|
|
113
|
+
services: ["weather"],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.html).toContain("__patchwork_createNamespaceProxy");
|
|
117
|
+
expect(result.html).toContain("weather");
|
|
118
|
+
expect(result.html).toContain("callServerTool");
|
|
119
|
+
expect(result.html).toContain("namespace + '__' + prop");
|
|
120
|
+
}, 60000);
|
|
121
|
+
|
|
122
|
+
it("compiled HTML contains CDN preload scripts and Tailwind", async () => {
|
|
123
|
+
const project = createProjectFromFiles(REFERENCE_WIDGET_FILES);
|
|
124
|
+
const result = await compileWidget(project, DASHBOARD_MANIFEST, {
|
|
125
|
+
services: ["weather"],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(result.html).toContain("esm.sh/react");
|
|
129
|
+
expect(result.html).toContain("tailwindcss");
|
|
130
|
+
expect(result.html).toContain("--background");
|
|
131
|
+
expect(result.html).toContain("--foreground");
|
|
132
|
+
}, 60000);
|
|
133
|
+
|
|
134
|
+
it("caches the compiled widget for repeated renders", async () => {
|
|
135
|
+
const project = createProjectFromFiles(REFERENCE_WIDGET_FILES);
|
|
136
|
+
|
|
137
|
+
const result1 = await compileWidget(project, DASHBOARD_MANIFEST, {
|
|
138
|
+
services: ["weather"],
|
|
139
|
+
});
|
|
140
|
+
expect(cacheHas(result1.hash)).toBe(true);
|
|
141
|
+
|
|
142
|
+
const result2 = await compileWidget(project, DASHBOARD_MANIFEST, {
|
|
143
|
+
services: ["weather"],
|
|
144
|
+
});
|
|
145
|
+
expect(result2.hash).toBe(result1.hash);
|
|
146
|
+
expect(result2.html).toBe(result1.html);
|
|
147
|
+
}, 60000);
|
|
148
|
+
|
|
149
|
+
it("compiles without services (no service shim)", async () => {
|
|
150
|
+
const project = createProjectFromFiles(REFERENCE_WIDGET_FILES);
|
|
151
|
+
const result = await compileWidget(project, DASHBOARD_MANIFEST);
|
|
152
|
+
|
|
153
|
+
expect(result.html).toContain("<!DOCTYPE html>");
|
|
154
|
+
expect(result.html).not.toContain("__patchwork_createNamespaceProxy");
|
|
155
|
+
expect(result.html).toContain("window.patchwork");
|
|
156
|
+
}, 60000);
|
|
157
|
+
|
|
158
|
+
it("compiles a single-file widget that uses services and live updates", async () => {
|
|
159
|
+
const simpleWidget = `export default function Widget() {
|
|
160
|
+
return <div className="p-4">Service widget</div>;
|
|
161
|
+
}`;
|
|
162
|
+
const manifest: Manifest = {
|
|
163
|
+
name: "simple-service",
|
|
164
|
+
version: "0.1.0",
|
|
165
|
+
platform: "browser",
|
|
166
|
+
image: "@aprovan/patchwork-image-shadcn",
|
|
167
|
+
services: ["stripe"],
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const result = await compileWidget(simpleWidget, manifest, {
|
|
171
|
+
services: ["stripe"],
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(result.html).toContain("<!DOCTYPE html>");
|
|
175
|
+
expect(result.html).toContain("stripe");
|
|
176
|
+
expect(result.html).toContain("__patchwork_createNamespaceProxy");
|
|
177
|
+
expect(result.html).toContain("window.patchwork");
|
|
178
|
+
}, 60000);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("E2E: service bridge integration", () => {
|
|
182
|
+
it("ServiceBridge forwards calls to the backend", async () => {
|
|
183
|
+
const bridge = new ServiceBridge({ backend: mockBackend, tools: mockTools });
|
|
184
|
+
|
|
185
|
+
const server = new McpServer({ name: "test-e2e", version: "0.1.0" });
|
|
186
|
+
bridge.registerTools(server);
|
|
187
|
+
bridge.registerSearchServices(server);
|
|
188
|
+
|
|
189
|
+
expect(bridge.getNamespaces()).toEqual(["weather"]);
|
|
190
|
+
expect(bridge.has("weather", "get_forecast")).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("ServiceBridge search_services returns available tools", async () => {
|
|
194
|
+
const bridge = new ServiceBridge({ backend: mockBackend, tools: mockTools });
|
|
195
|
+
const server = new McpServer({ name: "test-e2e", version: "0.1.0" });
|
|
196
|
+
bridge.registerTools(server);
|
|
197
|
+
bridge.registerSearchServices(server);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("mock backend returns structured data for weather calls", async () => {
|
|
201
|
+
const result = await mockBackend.call("weather", "get_forecast", [{ location: "San Francisco" }]);
|
|
202
|
+
expect(result).toEqual({
|
|
203
|
+
location: "San Francisco",
|
|
204
|
+
temperature: 22,
|
|
205
|
+
conditions: "partly cloudy",
|
|
206
|
+
forecast: [{ day: "today", high: 24, low: 18 }],
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("E2E: live update channel integration", () => {
|
|
212
|
+
beforeEach(() => {
|
|
213
|
+
_resetForTests();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("push_stream_update buffers events and notifies subscribed sessions", async () => {
|
|
217
|
+
const notify = vi.fn().mockResolvedValue(undefined);
|
|
218
|
+
const server = makeMockServer(notify);
|
|
219
|
+
|
|
220
|
+
registerSession("e2e-sess-1", server);
|
|
221
|
+
subscribeSession("e2e-sess-1", "price_feed");
|
|
222
|
+
|
|
223
|
+
const seq = await pushStreamUpdate("price_feed", {
|
|
224
|
+
AAPL: { price: 189.50, change: 1.23 },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(seq).toBeGreaterThan(0);
|
|
228
|
+
expect(notify).toHaveBeenCalledWith({
|
|
229
|
+
method: "notifications/tools/list_changed",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const events = getEvents("price_feed", 0);
|
|
233
|
+
expect(events).toHaveLength(1);
|
|
234
|
+
expect(events[0]!.data).toEqual({
|
|
235
|
+
AAPL: { price: 189.50, change: 1.23 },
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("multiple sequential pushes are retrieved with afterSeq", async () => {
|
|
240
|
+
const e1 = appendEvent("system_status", { service: "api", status: "ok" });
|
|
241
|
+
appendEvent("system_status", { service: "db", status: "warning" });
|
|
242
|
+
appendEvent("system_status", { service: "cache", status: "ok" });
|
|
243
|
+
|
|
244
|
+
const events = getEvents("system_status", e1.seq);
|
|
245
|
+
expect(events).toHaveLength(2);
|
|
246
|
+
|
|
247
|
+
const allEvents = getEvents("system_status", 0);
|
|
248
|
+
expect(allEvents).toHaveLength(3);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("poll_updates simulation: subscribe, push, poll", async () => {
|
|
252
|
+
const notify = vi.fn().mockResolvedValue(undefined);
|
|
253
|
+
const server = makeMockServer(notify);
|
|
254
|
+
|
|
255
|
+
registerSession("poll-sess", server);
|
|
256
|
+
subscribeSession("poll-sess", "price_feed");
|
|
257
|
+
|
|
258
|
+
const startSeq = currentSeq();
|
|
259
|
+
|
|
260
|
+
await pushStreamUpdate("price_feed", { AAPL: { price: 185.0, change: 0.5 } });
|
|
261
|
+
await pushStreamUpdate("price_feed", { GOOGL: { price: 142.30, change: -0.8 } });
|
|
262
|
+
|
|
263
|
+
const newEvents = getEvents("price_feed", startSeq);
|
|
264
|
+
expect(newEvents).toHaveLength(2);
|
|
265
|
+
|
|
266
|
+
expect(newEvents[0]!.data).toEqual({ AAPL: { price: 185.0, change: 0.5 } });
|
|
267
|
+
expect(newEvents[1]!.data).toEqual({ GOOGL: { price: 142.30, change: -0.8 } });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("unsubscribed sessions do not receive notifications", async () => {
|
|
271
|
+
const notify = vi.fn().mockResolvedValue(undefined);
|
|
272
|
+
const server = makeMockServer(notify);
|
|
273
|
+
|
|
274
|
+
registerSession("unsub-sess", server);
|
|
275
|
+
|
|
276
|
+
await pushStreamUpdate("price_feed", { tick: true });
|
|
277
|
+
expect(notify).not.toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("live update shim code includes subscribe/poll/push integration", () => {
|
|
281
|
+
const shim = generateLiveUpdateShim();
|
|
282
|
+
|
|
283
|
+
expect(shim).toContain("window.patchwork");
|
|
284
|
+
expect(shim).toContain("subscribe:");
|
|
285
|
+
expect(shim).toContain("subscribe_stream");
|
|
286
|
+
expect(shim).toContain("poll_updates");
|
|
287
|
+
expect(shim).toContain("notifications/tools/list_changed");
|
|
288
|
+
expect(shim).toContain("__pollStream");
|
|
289
|
+
expect(shim).toContain("after_seq");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("E2E: VFS store round-trip with compiled widget", () => {
|
|
294
|
+
beforeEach(() => {
|
|
295
|
+
clear();
|
|
296
|
+
resetWidgetStore();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("compile, persist, retrieve, and re-register as MCP resource", async () => {
|
|
300
|
+
const store = new WidgetStore({ backend: new MemoryBackend() });
|
|
301
|
+
const project = createProjectFromFiles(REFERENCE_WIDGET_FILES);
|
|
302
|
+
const result = await compileWidget(project, DASHBOARD_MANIFEST, {
|
|
303
|
+
services: ["weather"],
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await store.saveWidget(result.hash, result.html, DASHBOARD_MANIFEST, "main.tsx");
|
|
307
|
+
|
|
308
|
+
const stored = await store.getWidget(DASHBOARD_MANIFEST.name, result.hash);
|
|
309
|
+
expect(stored).not.toBeNull();
|
|
310
|
+
expect(stored!.html).toBe(result.html);
|
|
311
|
+
expect(stored!.entry).toBe("main.tsx");
|
|
312
|
+
|
|
313
|
+
expect(store.resourceUriFor(DASHBOARD_MANIFEST.name, result.hash)).toMatch(
|
|
314
|
+
/^ui:\/\/widgets\/live-dashboard\//,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const listed = await store.listWidgets();
|
|
318
|
+
expect(listed).toHaveLength(1);
|
|
319
|
+
expect(listed[0]!.name).toBe("live-dashboard");
|
|
320
|
+
expect(listed[0]!.services).toEqual(["weather"]);
|
|
321
|
+
expect(listed[0]!.entry).toBe("main.tsx");
|
|
322
|
+
|
|
323
|
+
await store.deleteWidget(DASHBOARD_MANIFEST.name, result.hash);
|
|
324
|
+
const deleted = await store.getWidget(DASHBOARD_MANIFEST.name, result.hash);
|
|
325
|
+
expect(deleted).toBeNull();
|
|
326
|
+
}, 60000);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("E2E: MCP server tool wiring", () => {
|
|
330
|
+
it("ServiceBridge + McpServer tool registration does not throw", () => {
|
|
331
|
+
const bridge = new ServiceBridge({ backend: mockBackend, tools: mockTools });
|
|
332
|
+
const server = new McpServer({ name: "test-e2e", version: "0.1.0" });
|
|
333
|
+
expect(() => {
|
|
334
|
+
bridge.registerTools(server);
|
|
335
|
+
bridge.registerSearchServices(server);
|
|
336
|
+
}).not.toThrow();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("McpServer can be constructed with service bridge config", () => {
|
|
340
|
+
const server = new McpServer({ name: "patchwork-mcp-app-server", version: "0.1.0" });
|
|
341
|
+
expect(server).toBeDefined();
|
|
342
|
+
|
|
343
|
+
const bridge = new ServiceBridge({ backend: mockBackend, tools: mockTools });
|
|
344
|
+
bridge.registerTools(server);
|
|
345
|
+
bridge.registerSearchServices(server);
|
|
346
|
+
|
|
347
|
+
expect(bridge.getNamespaces()).toContain("weather");
|
|
348
|
+
expect(bridge.getToolInfos()).toHaveLength(1);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("E2E: shim code generation for reference widget", () => {
|
|
353
|
+
it("service shim includes all requested namespaces", () => {
|
|
354
|
+
const shim = generateServiceShim({ namespaces: ["weather"] });
|
|
355
|
+
|
|
356
|
+
expect(shim).toContain("import { App } from");
|
|
357
|
+
expect(shim).toContain("esm.sh/@modelcontextprotocol/ext-apps");
|
|
358
|
+
expect(shim).toContain("weather");
|
|
359
|
+
expect(shim).toContain("__patchwork_createNamespaceProxy");
|
|
360
|
+
expect(shim).toContain("window[__ns]");
|
|
361
|
+
expect(shim).toContain("callServerTool");
|
|
362
|
+
expect(shim).toContain("namespace + '__' + prop");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("combined service + live-update shim is coherent", () => {
|
|
366
|
+
const serviceShim = generateServiceShim({ namespaces: ["weather"] });
|
|
367
|
+
const liveShim = generateLiveUpdateShim();
|
|
368
|
+
|
|
369
|
+
expect(serviceShim).toContain("__patchwork_app");
|
|
370
|
+
expect(liveShim).toContain("if (!window.__patchwork_app)");
|
|
371
|
+
expect(serviceShim).toContain("new App(");
|
|
372
|
+
expect(liveShim).toContain("window.patchwork");
|
|
373
|
+
|
|
374
|
+
expect(liveShim).toContain("__patchwork_ready");
|
|
375
|
+
expect(serviceShim).toContain("__patchwork_ready");
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe("E2E: multi-session live update broadcasting", () => {
|
|
380
|
+
beforeEach(() => {
|
|
381
|
+
_resetForTests();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("pushes to multiple sessions subscribed to the same stream", async () => {
|
|
385
|
+
const notify1 = vi.fn().mockResolvedValue(undefined);
|
|
386
|
+
const notify2 = vi.fn().mockResolvedValue(undefined);
|
|
387
|
+
const notify3 = vi.fn().mockResolvedValue(undefined);
|
|
388
|
+
|
|
389
|
+
registerSession("multi-1", makeMockServer(notify1));
|
|
390
|
+
registerSession("multi-2", makeMockServer(notify2));
|
|
391
|
+
registerSession("multi-3", makeMockServer(notify3));
|
|
392
|
+
|
|
393
|
+
subscribeSession("multi-1", "price_feed");
|
|
394
|
+
subscribeSession("multi-2", "price_feed");
|
|
395
|
+
subscribeSession("multi-3", "system_status");
|
|
396
|
+
|
|
397
|
+
await pushStreamUpdate("price_feed", { AAPL: 150 });
|
|
398
|
+
|
|
399
|
+
expect(notify1).toHaveBeenCalledOnce();
|
|
400
|
+
expect(notify2).toHaveBeenCalledOnce();
|
|
401
|
+
expect(notify3).not.toHaveBeenCalled();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("continues broadcasting when one session fails", async () => {
|
|
405
|
+
const failing = vi.fn().mockRejectedValue(new Error("transport closed"));
|
|
406
|
+
const ok = vi.fn().mockResolvedValue(undefined);
|
|
407
|
+
|
|
408
|
+
registerSession("fail-sess", makeMockServer(failing));
|
|
409
|
+
registerSession("ok-sess", makeMockServer(ok));
|
|
410
|
+
|
|
411
|
+
subscribeSession("fail-sess", "events");
|
|
412
|
+
subscribeSession("ok-sess", "events");
|
|
413
|
+
|
|
414
|
+
await expect(pushStreamUpdate("events", { msg: "test" })).resolves.toBeDefined();
|
|
415
|
+
expect(ok).toHaveBeenCalledOnce();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
appendEvent,
|
|
4
|
+
getEvents,
|
|
5
|
+
pushStreamUpdate,
|
|
6
|
+
currentSeq,
|
|
7
|
+
registerSession,
|
|
8
|
+
unregisterSession,
|
|
9
|
+
subscribeSession,
|
|
10
|
+
unsubscribeSession,
|
|
11
|
+
_resetForTests,
|
|
12
|
+
} from "../live-update.js";
|
|
13
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
|
|
15
|
+
// Minimal McpServer stub — `server.notification()` is the SDK's one-way push method
|
|
16
|
+
function makeMockServer(notificationFn = vi.fn().mockResolvedValue(undefined)) {
|
|
17
|
+
return {
|
|
18
|
+
server: { notification: notificationFn },
|
|
19
|
+
} as unknown as McpServer;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
_resetForTests();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("appendEvent / getEvents", () => {
|
|
27
|
+
it("stores events and retrieves them by afterSeq", () => {
|
|
28
|
+
appendEvent("prices", { price: 100 });
|
|
29
|
+
appendEvent("prices", { price: 101 });
|
|
30
|
+
const events = getEvents("prices", 0);
|
|
31
|
+
expect(events).toHaveLength(2);
|
|
32
|
+
expect(events[0]!.data).toEqual({ price: 100 });
|
|
33
|
+
expect(events[1]!.data).toEqual({ price: 101 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("only returns events after the given seq", () => {
|
|
37
|
+
appendEvent("prices", { price: 1 });
|
|
38
|
+
const e2 = appendEvent("prices", { price: 2 });
|
|
39
|
+
appendEvent("prices", { price: 3 });
|
|
40
|
+
|
|
41
|
+
const events = getEvents("prices", e2.seq);
|
|
42
|
+
expect(events).toHaveLength(1);
|
|
43
|
+
expect(events[0]!.data).toEqual({ price: 3 });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns empty array for unknown stream", () => {
|
|
47
|
+
expect(getEvents("unknown", 0)).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("assigns monotonically increasing seq numbers across streams", () => {
|
|
51
|
+
const a = appendEvent("a", 1);
|
|
52
|
+
const b = appendEvent("b", 2);
|
|
53
|
+
expect(b.seq).toBeGreaterThan(a.seq);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("evicts oldest events when buffer exceeds 100 entries", () => {
|
|
57
|
+
for (let i = 0; i < 110; i++) {
|
|
58
|
+
appendEvent("big", i);
|
|
59
|
+
}
|
|
60
|
+
// We can still get events after seq 0, but only the last 100
|
|
61
|
+
const events = getEvents("big", 0);
|
|
62
|
+
expect(events.length).toBe(100);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("currentSeq", () => {
|
|
67
|
+
it("starts at 0 and increments on append", () => {
|
|
68
|
+
expect(currentSeq()).toBe(0);
|
|
69
|
+
appendEvent("s", 1);
|
|
70
|
+
expect(currentSeq()).toBe(1);
|
|
71
|
+
appendEvent("s", 2);
|
|
72
|
+
expect(currentSeq()).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("session registry", () => {
|
|
77
|
+
it("registers and unregisters sessions", () => {
|
|
78
|
+
const server = makeMockServer();
|
|
79
|
+
registerSession("sess-1", server);
|
|
80
|
+
// After registering, subscribing should work without error
|
|
81
|
+
subscribeSession("sess-1", "prices");
|
|
82
|
+
unregisterSession("sess-1");
|
|
83
|
+
// After unregistering, subscribe is a no-op (doesn't throw)
|
|
84
|
+
expect(() => subscribeSession("sess-1", "prices")).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("unsubscribeSession removes a stream", () => {
|
|
88
|
+
const notify = vi.fn().mockResolvedValue(undefined);
|
|
89
|
+
const server = makeMockServer(notify);
|
|
90
|
+
registerSession("sess-2", server);
|
|
91
|
+
subscribeSession("sess-2", "prices");
|
|
92
|
+
unsubscribeSession("sess-2", "prices");
|
|
93
|
+
|
|
94
|
+
// pushStreamUpdate should not call notify since we unsubscribed
|
|
95
|
+
return pushStreamUpdate("prices", { price: 99 }).then(() => {
|
|
96
|
+
expect(notify).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("pushStreamUpdate", () => {
|
|
102
|
+
it("buffers the event and returns its seq", async () => {
|
|
103
|
+
const seq = await pushStreamUpdate("orders", { id: 1 });
|
|
104
|
+
expect(seq).toBeGreaterThan(0);
|
|
105
|
+
const events = getEvents("orders", 0);
|
|
106
|
+
expect(events).toHaveLength(1);
|
|
107
|
+
expect(events[0]!.data).toEqual({ id: 1 });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("sends notifications/tools/list_changed to subscribed sessions", async () => {
|
|
111
|
+
const notify = vi.fn().mockResolvedValue(undefined);
|
|
112
|
+
const server = makeMockServer(notify);
|
|
113
|
+
registerSession("sess-a", server);
|
|
114
|
+
subscribeSession("sess-a", "prices");
|
|
115
|
+
|
|
116
|
+
await pushStreamUpdate("prices", { price: 42 });
|
|
117
|
+
|
|
118
|
+
expect(notify).toHaveBeenCalledWith({
|
|
119
|
+
method: "notifications/tools/list_changed",
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not notify sessions subscribed to a different stream", async () => {
|
|
124
|
+
const notify = vi.fn().mockResolvedValue(undefined);
|
|
125
|
+
const server = makeMockServer(notify);
|
|
126
|
+
registerSession("sess-b", server);
|
|
127
|
+
subscribeSession("sess-b", "quotes"); // subscribed to "quotes", not "prices"
|
|
128
|
+
|
|
129
|
+
await pushStreamUpdate("prices", { price: 42 });
|
|
130
|
+
expect(notify).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("notifies multiple sessions subscribed to the same stream", async () => {
|
|
134
|
+
const notify1 = vi.fn().mockResolvedValue(undefined);
|
|
135
|
+
const notify2 = vi.fn().mockResolvedValue(undefined);
|
|
136
|
+
registerSession("sess-c", makeMockServer(notify1));
|
|
137
|
+
registerSession("sess-d", makeMockServer(notify2));
|
|
138
|
+
subscribeSession("sess-c", "trades");
|
|
139
|
+
subscribeSession("sess-d", "trades");
|
|
140
|
+
|
|
141
|
+
await pushStreamUpdate("trades", { trade: "AAPL" });
|
|
142
|
+
expect(notify1).toHaveBeenCalledOnce();
|
|
143
|
+
expect(notify2).toHaveBeenCalledOnce();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("continues notifying other sessions if one throws", async () => {
|
|
147
|
+
const failing = vi.fn().mockRejectedValue(new Error("transport closed"));
|
|
148
|
+
const ok = vi.fn().mockResolvedValue(undefined);
|
|
149
|
+
registerSession("sess-fail", makeMockServer(failing));
|
|
150
|
+
registerSession("sess-ok", makeMockServer(ok));
|
|
151
|
+
subscribeSession("sess-fail", "events");
|
|
152
|
+
subscribeSession("sess-ok", "events");
|
|
153
|
+
|
|
154
|
+
// Should not reject even if one session fails
|
|
155
|
+
await expect(pushStreamUpdate("events", { msg: "hi" })).resolves.toBeDefined();
|
|
156
|
+
expect(ok).toHaveBeenCalledOnce();
|
|
157
|
+
});
|
|
158
|
+
});
|