@aprovan/mcp-app-server 0.1.0-dev.4d82df8
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 +23 -0
- package/E2E_TESTING.md +224 -0
- package/LICENSE +373 -0
- package/README.md +164 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +990 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist/runtime/assets/index-tRNuU9ap.js +1059 -0
- package/dist/runtime/index.html +38 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +1511 -0
- package/dist/server.js.map +1 -0
- package/dist/shell/shell.js +67 -0
- package/docs/widget-preview.png +0 -0
- package/e2e/__snapshots__/.gitkeep +0 -0
- package/e2e/global-setup.ts +114 -0
- package/e2e/global-teardown.ts +15 -0
- package/e2e/visual-regression.test.ts +86 -0
- package/e2e/widget-smoke.test.ts +109 -0
- package/index.html +32 -0
- package/package.json +51 -0
- package/playwright.config.ts +43 -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 +60 -0
- package/src/__tests__/widget-store.test.ts +188 -0
- package/src/e2e-visual.ts +148 -0
- package/src/index.ts +608 -0
- package/src/live-update.ts +150 -0
- package/src/logger.ts +44 -0
- package/src/reference-widgets/live-dashboard.ts +162 -0
- package/src/registry-backend.ts +306 -0
- package/src/runtime/index.html +38 -0
- package/src/runtime/main.ts +164 -0
- package/src/scripts/export-artifacts.ts +51 -0
- package/src/server.ts +398 -0
- package/src/services.ts +307 -0
- package/src/shell/main.ts +172 -0
- package/src/shim.ts +106 -0
- package/src/tunnel.ts +123 -0
- package/src/widget-store/index.ts +10 -0
- package/src/widget-store/local-backend.ts +77 -0
- package/src/widget-store/store.ts +242 -0
- package/src/widget-store/types.ts +53 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +14 -0
- package/vite.runtime.config.ts +28 -0
- package/vite.shell.config.ts +30 -0
- package/vitest.config.ts +7 -0
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright global setup for visual regression tests.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* 1. Save the live-dashboard reference widget's RAW source files to an isolated
|
|
6
|
+
* widget store (no server-side compilation).
|
|
7
|
+
* 2. Start an in-process Express widget server on WIDGET_PORT (default 3002) that
|
|
8
|
+
* serves the shared browser runtime (dist/runtime) plus each widget's raw
|
|
9
|
+
* files at /widget/:name/:hash/files.
|
|
10
|
+
* 3. Write a test-fixtures.json file so tests can discover widget runtime URLs.
|
|
11
|
+
*
|
|
12
|
+
* Compilation now happens in the browser via @aprovan/patchwork-compiler — the
|
|
13
|
+
* same path the chat app uses — so the React UMD injection hack is gone. The
|
|
14
|
+
* widget's React is preloaded from the CDN by the image at mount time.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: dist/runtime must be built first (`pnpm build:runtime`); the e2e:visual
|
|
17
|
+
* script does this before invoking Playwright.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
21
|
+
import { createServer, type Server } from "node:http";
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import cors from "cors";
|
|
26
|
+
import express from "express";
|
|
27
|
+
import {
|
|
28
|
+
REFERENCE_WIDGET_FILES,
|
|
29
|
+
REFERENCE_WIDGET_MANIFEST,
|
|
30
|
+
} from "../src/reference-widgets/live-dashboard.js";
|
|
31
|
+
import { WidgetStore } from "../src/widget-store/store.js";
|
|
32
|
+
|
|
33
|
+
const WIDGET_PORT = Number(process.env["WIDGET_PORT"] ?? 3002);
|
|
34
|
+
const ARTIFACTS_DIR = join(process.cwd(), ".artifacts");
|
|
35
|
+
const STORE_DIR = join(ARTIFACTS_DIR, "widget-store-e2e");
|
|
36
|
+
const FIXTURES_PATH = join(ARTIFACTS_DIR, "test-fixtures.json");
|
|
37
|
+
const SCREENSHOTS_DIR = join(ARTIFACTS_DIR, "screenshots");
|
|
38
|
+
|
|
39
|
+
const RUNTIME_DIR = fileURLToPath(new URL("../dist/runtime", import.meta.url));
|
|
40
|
+
|
|
41
|
+
export interface WidgetFixture {
|
|
42
|
+
name: string;
|
|
43
|
+
hash: string;
|
|
44
|
+
url: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TestFixtures {
|
|
48
|
+
widgets: Record<string, WidgetFixture>;
|
|
49
|
+
serverPort: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let _server: Server | null = null;
|
|
53
|
+
|
|
54
|
+
export default async function globalSetup(): Promise<void> {
|
|
55
|
+
await mkdir(ARTIFACTS_DIR, { recursive: true });
|
|
56
|
+
await mkdir(SCREENSHOTS_DIR, { recursive: true });
|
|
57
|
+
await mkdir(STORE_DIR, { recursive: true });
|
|
58
|
+
|
|
59
|
+
if (!existsSync(RUNTIME_DIR)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Runtime bundle not found at ${RUNTIME_DIR}. Run \`pnpm build:runtime\` before e2e tests.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Use an isolated store for E2E tests to avoid polluting the default store
|
|
66
|
+
const store = new WidgetStore({ storageDir: STORE_DIR });
|
|
67
|
+
|
|
68
|
+
// Save the live-dashboard reference widget as RAW source files
|
|
69
|
+
const hash = "e2e-live-dashboard";
|
|
70
|
+
await store.saveWidget(hash, REFERENCE_WIDGET_FILES, REFERENCE_WIDGET_MANIFEST, "main.tsx");
|
|
71
|
+
|
|
72
|
+
const baseUrl = `http://localhost:${WIDGET_PORT}`;
|
|
73
|
+
const fixtures: TestFixtures = {
|
|
74
|
+
widgets: {
|
|
75
|
+
"live-dashboard": {
|
|
76
|
+
name: REFERENCE_WIDGET_MANIFEST.name,
|
|
77
|
+
hash,
|
|
78
|
+
url: `${baseUrl}/runtime/?widget=${REFERENCE_WIDGET_MANIFEST.name}/${hash}`,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
serverPort: WIDGET_PORT,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
await writeFile(FIXTURES_PATH, JSON.stringify(fixtures, null, 2), "utf-8");
|
|
85
|
+
|
|
86
|
+
// Start the Express widget server: shared runtime + raw widget files
|
|
87
|
+
const app = express();
|
|
88
|
+
app.use(cors());
|
|
89
|
+
app.use("/runtime", express.static(RUNTIME_DIR));
|
|
90
|
+
|
|
91
|
+
app.get("/widget/:name/:hash/files", async (req, res) => {
|
|
92
|
+
const { name, hash: h } = req.params;
|
|
93
|
+
const widget = await store.getWidget(name, h);
|
|
94
|
+
if (!widget) {
|
|
95
|
+
res.status(404).json({ error: "Widget not found" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
res.json({ files: widget.files, entry: widget.entry, manifest: widget.manifest });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
app.get("/health", (_req, res) => {
|
|
102
|
+
res.json({ status: "ok" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await new Promise<void>((resolve) => {
|
|
106
|
+
_server = createServer(app).listen(WIDGET_PORT, "127.0.0.1", () => {
|
|
107
|
+
resolve();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Expose the server reference for global teardown via the module cache.
|
|
112
|
+
// Playwright runs globalSetup and globalTeardown in the same Node.js process.
|
|
113
|
+
(globalThis as Record<string, unknown>).__e2eWidgetServer = _server;
|
|
114
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright global teardown — shuts down the in-process widget server started
|
|
3
|
+
* by global-setup.ts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Server } from "node:http";
|
|
7
|
+
|
|
8
|
+
export default async function globalTeardown(): Promise<void> {
|
|
9
|
+
const server = (globalThis as Record<string, unknown>).__e2eWidgetServer as
|
|
10
|
+
| Server
|
|
11
|
+
| undefined;
|
|
12
|
+
if (server) {
|
|
13
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual regression tests for Patchwork reference widgets.
|
|
3
|
+
*
|
|
4
|
+
* Each test:
|
|
5
|
+
* 1. Reads the widget runtime URL from test-fixtures.json (written by global-setup)
|
|
6
|
+
* 2. Navigates to the shared runtime host (/runtime/?widget=name/hash)
|
|
7
|
+
* - The runtime fetches the widget's raw source and compiles + mounts it in
|
|
8
|
+
* the browser via @aprovan/patchwork-compiler (esbuild-wasm + esm.sh)
|
|
9
|
+
* 3. Waits for the widget to mount (#root > *) and CDN resources to settle
|
|
10
|
+
* 4. Captures a full-page screenshot saved to .artifacts/screenshots/<widget-name>.png
|
|
11
|
+
* 5. Runs toHaveScreenshot() for visual regression baseline comparison
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFile, mkdir } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { test, expect } from "@playwright/test";
|
|
17
|
+
import type { TestFixtures } from "./global-setup.js";
|
|
18
|
+
|
|
19
|
+
const ARTIFACTS_DIR = join(process.cwd(), ".artifacts");
|
|
20
|
+
const FIXTURES_PATH = join(ARTIFACTS_DIR, "test-fixtures.json");
|
|
21
|
+
const SCREENSHOTS_DIR = join(ARTIFACTS_DIR, "screenshots");
|
|
22
|
+
|
|
23
|
+
async function loadFixtures(): Promise<TestFixtures> {
|
|
24
|
+
const raw = await readFile(FIXTURES_PATH, "utf-8");
|
|
25
|
+
return JSON.parse(raw) as TestFixtures;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test.describe("Widget visual regression", () => {
|
|
29
|
+
test("live-dashboard renders with Tailwind styles applied", async ({ page }) => {
|
|
30
|
+
const fixtures = await loadFixtures();
|
|
31
|
+
const widget = fixtures.widgets["live-dashboard"];
|
|
32
|
+
if (!widget) throw new Error("live-dashboard fixture not found — check global-setup");
|
|
33
|
+
|
|
34
|
+
// Inject a minimal window.patchwork stub before any page scripts run.
|
|
35
|
+
// The live-dashboard widget calls window.patchwork.subscribe() in a
|
|
36
|
+
// useEffect. In the test environment there is no MCP host, so the
|
|
37
|
+
// patchwork shim cannot connect and window.patchwork is never initialised.
|
|
38
|
+
// Without the stub React unmounts the tree on the thrown TypeError and
|
|
39
|
+
// #root stays empty.
|
|
40
|
+
await page.addInitScript(() => {
|
|
41
|
+
(window as unknown as Record<string, unknown>)["patchwork"] = {
|
|
42
|
+
subscribe: (_stream: string, _cb: unknown) => () => {},
|
|
43
|
+
fireEvent: () => {},
|
|
44
|
+
updateContext: () => {},
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Collect console errors for debugging on failure
|
|
49
|
+
const consoleErrors: string[] = [];
|
|
50
|
+
page.on("console", (msg) => {
|
|
51
|
+
if (msg.type() === "error") consoleErrors.push(msg.text());
|
|
52
|
+
});
|
|
53
|
+
page.on("pageerror", (err) => consoleErrors.push(err.message));
|
|
54
|
+
|
|
55
|
+
// Navigate to the shared runtime host, which compiles the widget in-browser
|
|
56
|
+
await page.goto(widget.url, { waitUntil: "domcontentloaded" });
|
|
57
|
+
|
|
58
|
+
// Wait for the widget to mount into #root (cold esbuild-wasm + CDN can be slow)
|
|
59
|
+
await page.waitForSelector("#root > *", {
|
|
60
|
+
timeout: 90_000,
|
|
61
|
+
state: "attached",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Wait for CDN network activity (Tailwind, any remaining esm.sh calls) to settle
|
|
65
|
+
await page.waitForLoadState("networkidle", { timeout: 60_000 });
|
|
66
|
+
|
|
67
|
+
if (consoleErrors.length > 0) {
|
|
68
|
+
console.warn("Browser console errors during test:", consoleErrors);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Verify the widget rendered its expected initial empty-state content
|
|
72
|
+
await expect(page.locator("text=Waiting for live data")).toBeVisible();
|
|
73
|
+
await expect(page.locator("text=Live Data Dashboard")).toBeVisible();
|
|
74
|
+
|
|
75
|
+
// Capture a named screenshot to .artifacts/screenshots/ for artifact upload
|
|
76
|
+
await mkdir(SCREENSHOTS_DIR, { recursive: true });
|
|
77
|
+
const screenshotPath = join(SCREENSHOTS_DIR, "live-dashboard.png");
|
|
78
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
79
|
+
|
|
80
|
+
// Visual regression baseline — created on first run, diffed on subsequent runs
|
|
81
|
+
await expect(page).toHaveScreenshot("live-dashboard.png", {
|
|
82
|
+
fullPage: true,
|
|
83
|
+
maxDiffPixelRatio: 0.02,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget visual smoke test.
|
|
3
|
+
*
|
|
4
|
+
* Saves a minimal widget's RAW source to an isolated store, serves the shared
|
|
5
|
+
* browser runtime + raw files over HTTP, then drives a headless Chromium browser
|
|
6
|
+
* to verify the runtime compiles and mounts the widget in-browser.
|
|
7
|
+
*
|
|
8
|
+
* This exercises the same path as production: no server-side compilation — the
|
|
9
|
+
* runtime fetches raw files and compiles them with @aprovan/patchwork-compiler.
|
|
10
|
+
*
|
|
11
|
+
* NOTE: dist/runtime must be built first (`pnpm build:runtime`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { test, expect } from "@playwright/test";
|
|
15
|
+
import { createServer, type Server } from "node:http";
|
|
16
|
+
import { mkdir, mkdtemp } from "node:fs/promises";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import cors from "cors";
|
|
22
|
+
import express from "express";
|
|
23
|
+
import { WidgetStore } from "../src/widget-store/store.js";
|
|
24
|
+
import type { Manifest, VirtualFile } from "@aprovan/patchwork-compiler";
|
|
25
|
+
|
|
26
|
+
const RUNTIME_DIR = fileURLToPath(new URL("../dist/runtime", import.meta.url));
|
|
27
|
+
|
|
28
|
+
const SIMPLE_WIDGET_FILES: VirtualFile[] = [
|
|
29
|
+
{
|
|
30
|
+
path: "main.tsx",
|
|
31
|
+
content: `
|
|
32
|
+
export default function Widget() {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
id="widget-content"
|
|
36
|
+
style={{ padding: "1.5rem", background: "#dbeafe", borderRadius: "8px" }}
|
|
37
|
+
>
|
|
38
|
+
<h1 style={{ margin: 0, fontSize: "1.25rem" }}>Hello from Patchwork</h1>
|
|
39
|
+
<p style={{ margin: "0.5rem 0 0" }}>Widget rendered successfully.</p>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
`,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const TEST_MANIFEST: Manifest = {
|
|
48
|
+
name: "smoke-test",
|
|
49
|
+
version: "0.1.0",
|
|
50
|
+
platform: "browser",
|
|
51
|
+
image: "@aprovan/patchwork-image-shadcn",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
test.describe("widget smoke", () => {
|
|
55
|
+
let httpServer: Server;
|
|
56
|
+
let widgetUrl: string;
|
|
57
|
+
|
|
58
|
+
test.beforeAll(async () => {
|
|
59
|
+
await mkdir(".artifacts/screenshots", { recursive: true });
|
|
60
|
+
|
|
61
|
+
if (!existsSync(RUNTIME_DIR)) {
|
|
62
|
+
throw new Error(`Runtime bundle missing at ${RUNTIME_DIR}. Run \`pnpm build:runtime\`.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const storeDir = await mkdtemp(join(tmpdir(), "pw-smoke-"));
|
|
66
|
+
const store = new WidgetStore({ storageDir: storeDir });
|
|
67
|
+
const hash = "smoke";
|
|
68
|
+
await store.saveWidget(hash, SIMPLE_WIDGET_FILES, TEST_MANIFEST, "main.tsx");
|
|
69
|
+
|
|
70
|
+
const app = express();
|
|
71
|
+
app.use(cors());
|
|
72
|
+
app.use("/runtime", express.static(RUNTIME_DIR));
|
|
73
|
+
app.get("/widget/:name/:hash/files", async (req, res) => {
|
|
74
|
+
const widget = await store.getWidget(req.params.name, req.params.hash);
|
|
75
|
+
if (!widget) {
|
|
76
|
+
res.status(404).json({ error: "not found" });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
res.json({ files: widget.files, entry: widget.entry, manifest: widget.manifest });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
httpServer = createServer(app);
|
|
83
|
+
await new Promise<void>((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
|
|
84
|
+
|
|
85
|
+
const addr = httpServer.address() as { address: string; port: number };
|
|
86
|
+
widgetUrl = `http://127.0.0.1:${addr.port}/runtime/?widget=${TEST_MANIFEST.name}/${hash}`;
|
|
87
|
+
}, 120_000);
|
|
88
|
+
|
|
89
|
+
test.afterAll(async () => {
|
|
90
|
+
await new Promise<void>((resolve, reject) =>
|
|
91
|
+
httpServer.close((err) => (err ? reject(err) : resolve()))
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("widget compiles and renders in browser", async ({ page }) => {
|
|
96
|
+
await page.goto(widgetUrl);
|
|
97
|
+
|
|
98
|
+
// The runtime mounts the compiled widget into #root
|
|
99
|
+
await expect(page.locator("#root > *")).toBeVisible({ timeout: 90_000 });
|
|
100
|
+
|
|
101
|
+
// Our widget's inner element should also be visible
|
|
102
|
+
await expect(page.locator("#widget-content")).toBeVisible();
|
|
103
|
+
|
|
104
|
+
await page.screenshot({
|
|
105
|
+
path: ".artifacts/screenshots/widget-smoke.png",
|
|
106
|
+
fullPage: true,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
package/index.html
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>timer</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
html,
|
|
14
|
+
body {
|
|
15
|
+
width: 100%;
|
|
16
|
+
height: 100%;
|
|
17
|
+
}
|
|
18
|
+
iframe {
|
|
19
|
+
width: 100%;
|
|
20
|
+
height: 100%;
|
|
21
|
+
border: none;
|
|
22
|
+
}
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<iframe
|
|
27
|
+
src="https://cloudy-hair-camcorders-open.trycloudflare.com/widget/timer/c177be55eb568a0b"
|
|
28
|
+
title="timer"
|
|
29
|
+
sandbox="allow-scripts allow-same-origin"
|
|
30
|
+
></iframe>
|
|
31
|
+
</body>
|
|
32
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aprovan/mcp-app-server",
|
|
3
|
+
"version": "0.1.0-dev.4d82df8",
|
|
4
|
+
"description": "MCP App Server — hosts MCP tools that render interactive UIs in Claude Desktop and Claude web.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"mcp-app-server": "./dist/server.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/ext-apps": "^1.7.3",
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
20
|
+
"cors": "^2.8.5",
|
|
21
|
+
"express": "^5.1.0",
|
|
22
|
+
"zod": "^3.23.0",
|
|
23
|
+
"@aprovan/patchwork-compiler": "0.1.2-dev.4d82df8",
|
|
24
|
+
"@aprovan/patchwork-image-shadcn": "0.1.0-dev.4d82df8"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@playwright/test": "^1.60.0",
|
|
28
|
+
"@types/cors": "^2.8.17",
|
|
29
|
+
"@types/express": "^5.0.2",
|
|
30
|
+
"@types/node": "^22.10.5",
|
|
31
|
+
"tsup": "^8.3.5",
|
|
32
|
+
"tsx": "^4.19.2",
|
|
33
|
+
"typescript": "^5.7.3",
|
|
34
|
+
"vite": "^6.3.5"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20.0.0"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsup && vite build --config vite.runtime.config.ts && vite build --config vite.shell.config.ts",
|
|
41
|
+
"build:runtime": "vite build --config vite.runtime.config.ts",
|
|
42
|
+
"build:shell": "vite build --config vite.shell.config.ts",
|
|
43
|
+
"check-types": "tsc --noEmit",
|
|
44
|
+
"dev": "tsx watch src/server.ts",
|
|
45
|
+
"start": "node dist/server.js",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run src/",
|
|
48
|
+
"e2e:visual": "pnpm build:runtime && playwright test --config playwright.config.ts",
|
|
49
|
+
"artifacts:export": "tsx src/scripts/export-artifacts.ts"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const ARTIFACTS_DIR = join(process.cwd(), ".artifacts");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Playwright configuration for visual widget tests.
|
|
8
|
+
*
|
|
9
|
+
* Run with: pnpm run e2e:visual
|
|
10
|
+
*
|
|
11
|
+
* Setup required before first run:
|
|
12
|
+
* npx playwright install chromium
|
|
13
|
+
*
|
|
14
|
+
* Global setup starts an in-process Express widget server and compiles
|
|
15
|
+
* reference widgets before any tests run, so no external server is needed.
|
|
16
|
+
*/
|
|
17
|
+
export default defineConfig({
|
|
18
|
+
testDir: "./e2e",
|
|
19
|
+
fullyParallel: false,
|
|
20
|
+
forbidOnly: !!process.env["CI"],
|
|
21
|
+
retries: process.env["CI"] ? 1 : 0,
|
|
22
|
+
workers: 1,
|
|
23
|
+
timeout: 120_000,
|
|
24
|
+
globalSetup: "./e2e/global-setup.ts",
|
|
25
|
+
globalTeardown: "./e2e/global-teardown.ts",
|
|
26
|
+
snapshotDir: join(ARTIFACTS_DIR, "snapshots"),
|
|
27
|
+
outputDir: join(ARTIFACTS_DIR, "playwright"),
|
|
28
|
+
reporter: [
|
|
29
|
+
["list"],
|
|
30
|
+
["html", { outputFolder: join(ARTIFACTS_DIR, "playwright-report"), open: "never" }],
|
|
31
|
+
],
|
|
32
|
+
use: {
|
|
33
|
+
baseURL: "http://localhost:3002",
|
|
34
|
+
screenshot: "on",
|
|
35
|
+
trace: "off",
|
|
36
|
+
},
|
|
37
|
+
projects: [
|
|
38
|
+
{
|
|
39
|
+
name: "chromium",
|
|
40
|
+
use: { ...devices["Desktop Chrome"] },
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
});
|
|
@@ -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
|
+
});
|