@aprovan/patchwork-mcp 0.1.0-dev.030eb3d
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/LICENSE +373 -0
- package/README.md +15 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +989 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist/runtime/assets/index-CSnUKUMa.js +1058 -0
- package/dist/runtime/index.html +38 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +1400 -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 +55 -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__/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 +47 -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 +399 -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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
6
|
+
import { LocalFileBackend } from "../widget-store/local-backend.js";
|
|
7
|
+
|
|
8
|
+
let testDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
testDir = resolve(tmpdir(), `widget-store-test-${randomUUID()}`);
|
|
12
|
+
await mkdir(testDir, { recursive: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await rm(testDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("LocalFileBackend", () => {
|
|
20
|
+
it("writes and reads files", async () => {
|
|
21
|
+
const backend = new LocalFileBackend(testDir);
|
|
22
|
+
await backend.writeFile("test.txt", "hello");
|
|
23
|
+
const content = await backend.readFile("test.txt");
|
|
24
|
+
expect(content).toBe("hello");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("creates parent directories on write", async () => {
|
|
28
|
+
const backend = new LocalFileBackend(testDir);
|
|
29
|
+
await backend.writeFile("sub/dir/file.txt", "nested");
|
|
30
|
+
const content = await backend.readFile("sub/dir/file.txt");
|
|
31
|
+
expect(content).toBe("nested");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("checks file existence", async () => {
|
|
35
|
+
const backend = new LocalFileBackend(testDir);
|
|
36
|
+
expect(await backend.exists("missing.txt")).toBe(false);
|
|
37
|
+
await backend.writeFile("exists.txt", "yes");
|
|
38
|
+
expect(await backend.exists("exists.txt")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("deletes files", async () => {
|
|
42
|
+
const backend = new LocalFileBackend(testDir);
|
|
43
|
+
await backend.writeFile("todelete.txt", "bye");
|
|
44
|
+
await backend.unlink("todelete.txt");
|
|
45
|
+
expect(await backend.exists("todelete.txt")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("stats files and directories", async () => {
|
|
49
|
+
const backend = new LocalFileBackend(testDir);
|
|
50
|
+
await backend.writeFile("file.txt", "content");
|
|
51
|
+
const fileStat = await backend.stat("file.txt");
|
|
52
|
+
expect(fileStat.isFile()).toBe(true);
|
|
53
|
+
expect(fileStat.size).toBe(7);
|
|
54
|
+
|
|
55
|
+
await backend.mkdir("mydir");
|
|
56
|
+
const dirStat = await backend.stat("mydir");
|
|
57
|
+
expect(dirStat.isDirectory()).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("lists directory entries", async () => {
|
|
61
|
+
const backend = new LocalFileBackend(testDir);
|
|
62
|
+
await backend.writeFile("a.txt", "a");
|
|
63
|
+
await backend.writeFile("b.txt", "b");
|
|
64
|
+
await backend.mkdir("subdir");
|
|
65
|
+
|
|
66
|
+
const entries = await backend.readdir("");
|
|
67
|
+
const names = entries.map((e) => e.name);
|
|
68
|
+
expect(names).toContain("a.txt");
|
|
69
|
+
expect(names).toContain("b.txt");
|
|
70
|
+
expect(names).toContain("subdir");
|
|
71
|
+
|
|
72
|
+
const subdirEntry = entries.find((e) => e.name === "subdir");
|
|
73
|
+
expect(subdirEntry!.isDirectory()).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("removes directories recursively", async () => {
|
|
77
|
+
const backend = new LocalFileBackend(testDir);
|
|
78
|
+
await backend.mkdir("sub/nested", { recursive: true });
|
|
79
|
+
await backend.writeFile("sub/nested/file.txt", "data");
|
|
80
|
+
|
|
81
|
+
await backend.rmdir("sub", { recursive: true });
|
|
82
|
+
expect(await backend.exists("sub")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("survives widget store pattern (save + load)", async () => {
|
|
86
|
+
const backend = new LocalFileBackend(testDir);
|
|
87
|
+
|
|
88
|
+
await backend.writeFile("widgets/my-widget/abc123/view.html", "<html>test</html>");
|
|
89
|
+
await backend.writeFile(
|
|
90
|
+
"widgets/my-widget/abc123/manifest.json",
|
|
91
|
+
JSON.stringify({ name: "my-widget", version: "1.0", hash: "abc123" }),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const html = await backend.readFile("widgets/my-widget/abc123/view.html");
|
|
95
|
+
expect(html).toBe("<html>test</html>");
|
|
96
|
+
|
|
97
|
+
const manifest = JSON.parse(await backend.readFile("widgets/my-widget/abc123/manifest.json"));
|
|
98
|
+
expect(manifest.name).toBe("my-widget");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { DirEntry, FileStats, FSProvider } from "../widget-store/types.js";
|
|
2
|
+
|
|
3
|
+
function createDirEntry(name: string, isDir: boolean): DirEntry {
|
|
4
|
+
return {
|
|
5
|
+
name,
|
|
6
|
+
isFile: () => !isDir,
|
|
7
|
+
isDirectory: () => isDir,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function createFileStats(size: number, mtime: Date, isDir: boolean): FileStats {
|
|
12
|
+
return {
|
|
13
|
+
size,
|
|
14
|
+
mtime,
|
|
15
|
+
isFile: () => !isDir,
|
|
16
|
+
isDirectory: () => isDir,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FileEntry {
|
|
21
|
+
content: string;
|
|
22
|
+
mtime: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class MemoryBackend implements FSProvider {
|
|
26
|
+
private files = new Map<string, FileEntry>();
|
|
27
|
+
private dirs = new Set<string>([""]);
|
|
28
|
+
|
|
29
|
+
async readFile(path: string): Promise<string> {
|
|
30
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
31
|
+
const entry = this.files.get(normalized);
|
|
32
|
+
if (!entry) throw new Error(`ENOENT: ${path}`);
|
|
33
|
+
return entry.content;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async writeFile(path: string, content: string): Promise<void> {
|
|
37
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
38
|
+
const dir = normalized.split("/").slice(0, -1).join("/");
|
|
39
|
+
if (dir && !this.dirs.has(dir)) {
|
|
40
|
+
await this.mkdir(dir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
this.files.set(normalized, { content, mtime: new Date() });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async unlink(path: string): Promise<void> {
|
|
46
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
47
|
+
if (!this.files.delete(normalized)) {
|
|
48
|
+
throw new Error(`ENOENT: ${path}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async stat(path: string): Promise<FileStats> {
|
|
53
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
54
|
+
const entry = this.files.get(normalized);
|
|
55
|
+
if (entry) {
|
|
56
|
+
return createFileStats(entry.content.length, entry.mtime, false);
|
|
57
|
+
}
|
|
58
|
+
if (this.dirs.has(normalized)) {
|
|
59
|
+
return createFileStats(0, new Date(), true);
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`ENOENT: ${path}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
|
65
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
66
|
+
if (this.dirs.has(normalized)) return;
|
|
67
|
+
|
|
68
|
+
const parent = normalized.split("/").slice(0, -1).join("/");
|
|
69
|
+
if (parent && !this.dirs.has(parent)) {
|
|
70
|
+
if (options?.recursive) {
|
|
71
|
+
await this.mkdir(parent, options);
|
|
72
|
+
} else {
|
|
73
|
+
throw new Error(`ENOENT: ${parent}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.dirs.add(normalized);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async readdir(path: string): Promise<DirEntry[]> {
|
|
80
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
81
|
+
if (!this.dirs.has(normalized)) {
|
|
82
|
+
throw new Error(`ENOENT: ${path}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const prefix = normalized ? `${normalized}/` : "";
|
|
86
|
+
const entries = new Map<string, boolean>();
|
|
87
|
+
|
|
88
|
+
for (const filePath of this.files.keys()) {
|
|
89
|
+
if (filePath.startsWith(prefix)) {
|
|
90
|
+
const rest = filePath.slice(prefix.length);
|
|
91
|
+
const name = rest.split("/")[0];
|
|
92
|
+
if (name) entries.set(name, false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const dirPath of this.dirs) {
|
|
97
|
+
if (dirPath.startsWith(prefix) && dirPath !== normalized) {
|
|
98
|
+
const rest = dirPath.slice(prefix.length);
|
|
99
|
+
const name = rest.split("/")[0];
|
|
100
|
+
if (name) entries.set(name, true);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return Array.from(entries).map(([name, isDir]) =>
|
|
105
|
+
createDirEntry(name, isDir),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async rmdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
|
110
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
111
|
+
if (!this.dirs.has(normalized)) {
|
|
112
|
+
throw new Error(`ENOENT: ${path}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const prefix = `${normalized}/`;
|
|
116
|
+
const hasChildren =
|
|
117
|
+
[...this.files.keys()].some((p) => p.startsWith(prefix)) ||
|
|
118
|
+
[...this.dirs].some((d) => d.startsWith(prefix));
|
|
119
|
+
|
|
120
|
+
if (hasChildren && !options?.recursive) {
|
|
121
|
+
throw new Error(`ENOTEMPTY: ${path}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (options?.recursive) {
|
|
125
|
+
for (const filePath of [...this.files.keys()]) {
|
|
126
|
+
if (filePath.startsWith(prefix)) {
|
|
127
|
+
this.files.delete(filePath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const dirPath of [...this.dirs]) {
|
|
131
|
+
if (dirPath.startsWith(prefix)) {
|
|
132
|
+
this.dirs.delete(dirPath);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.dirs.delete(normalized);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async exists(path: string): Promise<boolean> {
|
|
141
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
142
|
+
return this.files.has(normalized) || this.dirs.has(normalized);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { ServiceBridge, type ServiceBackend, type ServiceToolInfo } from "../services.js";
|
|
4
|
+
|
|
5
|
+
const mockBackend: ServiceBackend = {
|
|
6
|
+
call: vi.fn(async () => ({
|
|
7
|
+
result: "mock-data",
|
|
8
|
+
})),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const mockTools: ServiceToolInfo[] = [
|
|
12
|
+
{
|
|
13
|
+
name: "weather.get_forecast",
|
|
14
|
+
namespace: "weather",
|
|
15
|
+
procedure: "get_forecast",
|
|
16
|
+
description: "Get weather forecast for a location",
|
|
17
|
+
parameters: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
latitude: { type: "number", description: "Latitude" },
|
|
21
|
+
longitude: { type: "number", description: "Longitude" },
|
|
22
|
+
},
|
|
23
|
+
required: ["latitude", "longitude"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "weather.get_current_conditions",
|
|
28
|
+
namespace: "weather",
|
|
29
|
+
procedure: "get_current_conditions",
|
|
30
|
+
description: "Get current weather conditions",
|
|
31
|
+
parameters: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
location: { type: "string", description: "Location name" },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "stripe.create_payment",
|
|
40
|
+
namespace: "stripe",
|
|
41
|
+
procedure: "create_payment",
|
|
42
|
+
description: "Create a payment intent",
|
|
43
|
+
parameters: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
amount: { type: "number", description: "Amount in cents" },
|
|
47
|
+
currency: { type: "string", description: "Currency code" },
|
|
48
|
+
},
|
|
49
|
+
required: ["amount"],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
describe("ServiceBridge", () => {
|
|
55
|
+
describe("constructor", () => {
|
|
56
|
+
it("stores tools from config", () => {
|
|
57
|
+
const bridge = new ServiceBridge({
|
|
58
|
+
backend: mockBackend,
|
|
59
|
+
tools: mockTools,
|
|
60
|
+
});
|
|
61
|
+
expect(bridge.getToolInfos()).toHaveLength(3);
|
|
62
|
+
expect(bridge.getNamespaces()).toEqual(["weather", "stripe"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles empty tools array", () => {
|
|
66
|
+
const bridge = new ServiceBridge({
|
|
67
|
+
backend: mockBackend,
|
|
68
|
+
tools: [],
|
|
69
|
+
});
|
|
70
|
+
expect(bridge.getToolInfos()).toHaveLength(0);
|
|
71
|
+
expect(bridge.getNamespaces()).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("getNamespaces", () => {
|
|
76
|
+
it("returns unique namespaces", () => {
|
|
77
|
+
const bridge = new ServiceBridge({
|
|
78
|
+
backend: mockBackend,
|
|
79
|
+
tools: mockTools,
|
|
80
|
+
});
|
|
81
|
+
const namespaces = bridge.getNamespaces();
|
|
82
|
+
expect(namespaces).toContain("weather");
|
|
83
|
+
expect(namespaces).toContain("stripe");
|
|
84
|
+
expect(namespaces).toHaveLength(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("has", () => {
|
|
89
|
+
it("returns true for existing tools", () => {
|
|
90
|
+
const bridge = new ServiceBridge({
|
|
91
|
+
backend: mockBackend,
|
|
92
|
+
tools: mockTools,
|
|
93
|
+
});
|
|
94
|
+
expect(bridge.has("weather", "get_forecast")).toBe(true);
|
|
95
|
+
expect(bridge.has("stripe", "create_payment")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns false for missing tools", () => {
|
|
99
|
+
const bridge = new ServiceBridge({
|
|
100
|
+
backend: mockBackend,
|
|
101
|
+
tools: mockTools,
|
|
102
|
+
});
|
|
103
|
+
expect(bridge.has("weather", "nonexistent")).toBe(false);
|
|
104
|
+
expect(bridge.has("unknown", "something")).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("registerTools", () => {
|
|
109
|
+
it("registers service tools on the MCP server", () => {
|
|
110
|
+
const bridge = new ServiceBridge({
|
|
111
|
+
backend: mockBackend,
|
|
112
|
+
tools: mockTools,
|
|
113
|
+
});
|
|
114
|
+
const server = new McpServer({
|
|
115
|
+
name: "test-server",
|
|
116
|
+
version: "0.1.0",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
bridge.registerTools(server);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("registerSearchServices", () => {
|
|
124
|
+
it("registers search_services tool on the MCP server", () => {
|
|
125
|
+
const bridge = new ServiceBridge({
|
|
126
|
+
backend: mockBackend,
|
|
127
|
+
tools: mockTools,
|
|
128
|
+
});
|
|
129
|
+
const server = new McpServer({
|
|
130
|
+
name: "test-server",
|
|
131
|
+
version: "0.1.0",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
bridge.registerSearchServices(server);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("tool name mapping", () => {
|
|
139
|
+
it("uses __ separator for MCP tool names (not dots)", () => {
|
|
140
|
+
const bridge = new ServiceBridge({
|
|
141
|
+
backend: mockBackend,
|
|
142
|
+
tools: mockTools,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// The internal tool names use dots, but MCP tool names use __
|
|
146
|
+
// We can verify this by checking the tool infos still have dot names
|
|
147
|
+
const toolInfo = bridge.getToolInfos();
|
|
148
|
+
expect(toolInfo[0]?.name).toBe("weather.get_forecast");
|
|
149
|
+
expect(toolInfo[0]?.namespace).toBe("weather");
|
|
150
|
+
expect(toolInfo[0]?.procedure).toBe("get_forecast");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { generateBridgeShim } from "../shim.js";
|
|
3
|
+
|
|
4
|
+
describe("generateBridgeShim", () => {
|
|
5
|
+
it("exposes window.patchwork with subscribe/updateContext/fireEvent", () => {
|
|
6
|
+
const shim = generateBridgeShim({ namespaces: [] });
|
|
7
|
+
expect(shim).toContain("window.patchwork");
|
|
8
|
+
expect(shim).toContain("subscribe:");
|
|
9
|
+
expect(shim).toContain("updateContext:");
|
|
10
|
+
expect(shim).toContain("fireEvent:");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("forwards calls to the parent shell over postMessage", () => {
|
|
14
|
+
const shim = generateBridgeShim({ namespaces: ["weather"] });
|
|
15
|
+
expect(shim).toContain("window.parent.postMessage");
|
|
16
|
+
expect(shim).toContain("'patchwork'");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("creates a proxy for each service namespace", () => {
|
|
20
|
+
const shim = generateBridgeShim({ namespaces: ["weather", "stripe"] });
|
|
21
|
+
expect(shim).toContain('"weather"');
|
|
22
|
+
expect(shim).toContain('"stripe"');
|
|
23
|
+
expect(shim).toContain("new Proxy");
|
|
24
|
+
expect(shim).toContain("kind: 'service'");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("sends a fire event for fireEvent calls", () => {
|
|
28
|
+
const shim = generateBridgeShim({ namespaces: [] });
|
|
29
|
+
expect(shim).toContain("kind: 'fire'");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("sends a subscribe message for subscribe calls", () => {
|
|
33
|
+
const shim = generateBridgeShim({ namespaces: [] });
|
|
34
|
+
expect(shim).toContain("kind: 'subscribe'");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("sends a context message for updateContext calls", () => {
|
|
38
|
+
const shim = generateBridgeShim({ namespaces: [] });
|
|
39
|
+
expect(shim).toContain("kind: 'context'");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("listens for host messages and resolves pending requests", () => {
|
|
43
|
+
const shim = generateBridgeShim({ namespaces: ["weather"] });
|
|
44
|
+
expect(shim).toContain("'patchwork-host'");
|
|
45
|
+
expect(shim).toContain("stream-event");
|
|
46
|
+
expect(shim).toContain("p.resolve");
|
|
47
|
+
expect(shim).toContain("p.reject");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("guards against double-injection", () => {
|
|
51
|
+
const shim = generateBridgeShim({ namespaces: [] });
|
|
52
|
+
expect(shim).toContain("if (window.patchwork) return");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("is valid, evaluable JavaScript", () => {
|
|
56
|
+
const shim = generateBridgeShim({ namespaces: ["weather"] });
|
|
57
|
+
// Should parse without throwing.
|
|
58
|
+
expect(() => new Function(shim)).not.toThrow();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { WidgetStore, resetWidgetStore } from "../widget-store/store.js";
|
|
3
|
+
import { MemoryBackend } from "./memory-backend.js";
|
|
4
|
+
import type { Manifest, VirtualFile } from "@aprovan/patchwork-compiler";
|
|
5
|
+
|
|
6
|
+
const TEST_MANIFEST: Manifest = {
|
|
7
|
+
name: "test-widget",
|
|
8
|
+
version: "0.1.0",
|
|
9
|
+
platform: "browser",
|
|
10
|
+
image: "@aprovan/patchwork-image-shadcn",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const SINGLE_FILE: VirtualFile[] = [
|
|
14
|
+
{ path: "main.tsx", content: "export default () => <div>test</div>;" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function createStore(): WidgetStore {
|
|
18
|
+
resetWidgetStore();
|
|
19
|
+
return new WidgetStore({ backend: new MemoryBackend() });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("WidgetStore", () => {
|
|
23
|
+
let store: WidgetStore;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
store = createStore();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("saveWidget", () => {
|
|
30
|
+
it("persists raw widget files and returns stored metadata", async () => {
|
|
31
|
+
const result = await store.saveWidget("abc123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
32
|
+
|
|
33
|
+
expect(result.path).toBe("widgets/test-widget/abc123");
|
|
34
|
+
expect(result.resourceUri).toBe("ui://widgets/test-widget/abc123/view.html");
|
|
35
|
+
expect(result.files).toEqual(SINGLE_FILE);
|
|
36
|
+
expect(result.entry).toBe("main.tsx");
|
|
37
|
+
expect(result.manifest.name).toBe("test-widget");
|
|
38
|
+
expect(result.createdAt).toBeTypeOf("number");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("persists multi-file projects with nested paths", async () => {
|
|
42
|
+
const files: VirtualFile[] = [
|
|
43
|
+
{ path: "main.tsx", content: "import './ui/card';" },
|
|
44
|
+
{ path: "ui/card.tsx", content: "export const Card = () => null;" },
|
|
45
|
+
];
|
|
46
|
+
await store.saveWidget("def456", files, { ...TEST_MANIFEST, name: "multi" }, "main.tsx");
|
|
47
|
+
|
|
48
|
+
const widget = await store.getWidget("multi", "def456");
|
|
49
|
+
expect(widget!.files).toHaveLength(2);
|
|
50
|
+
expect(widget!.files.map((f) => f.path).sort()).toEqual(["main.tsx", "ui/card.tsx"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("stores manifest with services metadata", async () => {
|
|
54
|
+
const manifest: Manifest = { ...TEST_MANIFEST, services: ["weather", "github"] };
|
|
55
|
+
const result = await store.saveWidget("svc123", SINGLE_FILE, manifest, "main.tsx");
|
|
56
|
+
|
|
57
|
+
expect(result.manifest.services).toEqual(["weather", "github"]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("getWidget", () => {
|
|
62
|
+
it("retrieves stored raw files by name and hash", async () => {
|
|
63
|
+
await store.saveWidget("abc123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
64
|
+
const widget = await store.getWidget("test-widget", "abc123");
|
|
65
|
+
|
|
66
|
+
expect(widget).not.toBeNull();
|
|
67
|
+
expect(widget!.files).toEqual(SINGLE_FILE);
|
|
68
|
+
expect(widget!.manifest.name).toBe("test-widget");
|
|
69
|
+
expect(widget!.resourceUri).toBe("ui://widgets/test-widget/abc123/view.html");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns null for non-existent widget", async () => {
|
|
73
|
+
const widget = await store.getWidget("nonexistent", "nothash");
|
|
74
|
+
expect(widget).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("retrieves entry point from manifest", async () => {
|
|
78
|
+
await store.saveWidget("ent123", SINGLE_FILE, TEST_MANIFEST, "app.tsx");
|
|
79
|
+
const widget = await store.getWidget("test-widget", "ent123");
|
|
80
|
+
|
|
81
|
+
expect(widget!.entry).toBe("app.tsx");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("listWidgets", () => {
|
|
86
|
+
it("returns empty list when no widgets stored", async () => {
|
|
87
|
+
const widgets = await store.listWidgets();
|
|
88
|
+
expect(widgets).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("lists all stored widgets with metadata", async () => {
|
|
92
|
+
await store.saveWidget("abc123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
93
|
+
await store.saveWidget(
|
|
94
|
+
"def456",
|
|
95
|
+
SINGLE_FILE,
|
|
96
|
+
{ ...TEST_MANIFEST, name: "other-widget", description: "Another widget" },
|
|
97
|
+
"main.tsx",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const widgets = await store.listWidgets();
|
|
101
|
+
expect(widgets).toHaveLength(2);
|
|
102
|
+
expect(widgets.map((w) => w.name)).toContain("test-widget");
|
|
103
|
+
expect(widgets.map((w) => w.name)).toContain("other-widget");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("includes services and entry in listing", async () => {
|
|
107
|
+
await store.saveWidget(
|
|
108
|
+
"svc123",
|
|
109
|
+
SINGLE_FILE,
|
|
110
|
+
{ ...TEST_MANIFEST, services: ["stripe"] },
|
|
111
|
+
"main.tsx",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const widgets = await store.listWidgets();
|
|
115
|
+
const entry = widgets.find((w) => w.name === "test-widget");
|
|
116
|
+
expect(entry!.services).toEqual(["stripe"]);
|
|
117
|
+
expect(entry!.entry).toBe("main.tsx");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns widgets sorted by most recent first", async () => {
|
|
121
|
+
const store = createStore();
|
|
122
|
+
await store.saveWidget("old", SINGLE_FILE, { ...TEST_MANIFEST, name: "old-widget" }, "main.tsx");
|
|
123
|
+
|
|
124
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
125
|
+
|
|
126
|
+
await store.saveWidget("new", SINGLE_FILE, { ...TEST_MANIFEST, name: "new-widget" }, "main.tsx");
|
|
127
|
+
|
|
128
|
+
const widgets = await store.listWidgets();
|
|
129
|
+
expect(widgets[0]!.name).toBe("new-widget");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("deleteWidget", () => {
|
|
134
|
+
it("deletes a stored widget", async () => {
|
|
135
|
+
await store.saveWidget("del123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
136
|
+
const deleted = await store.deleteWidget("test-widget", "del123");
|
|
137
|
+
expect(deleted).toBe(true);
|
|
138
|
+
|
|
139
|
+
const widget = await store.getWidget("test-widget", "del123");
|
|
140
|
+
expect(widget).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns false for non-existent widget", async () => {
|
|
144
|
+
const deleted = await store.deleteWidget("nonexistent", "nothash");
|
|
145
|
+
expect(deleted).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("hasWidget", () => {
|
|
150
|
+
it("returns true for stored widget", async () => {
|
|
151
|
+
await store.saveWidget("has123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
152
|
+
expect(await store.hasWidget("test-widget", "has123")).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("returns false for non-existent widget", async () => {
|
|
156
|
+
expect(await store.hasWidget("nonexistent", "nothash")).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("resourceUriFor", () => {
|
|
161
|
+
it("generates correct resource URI", () => {
|
|
162
|
+
const uri = store.resourceUriFor("my-widget", "abc123");
|
|
163
|
+
expect(uri).toBe("ui://widgets/my-widget/abc123/view.html");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("loadAll", () => {
|
|
168
|
+
it("loads all stored widgets", async () => {
|
|
169
|
+
await store.saveWidget("abc123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
170
|
+
await store.saveWidget(
|
|
171
|
+
"def456",
|
|
172
|
+
SINGLE_FILE,
|
|
173
|
+
{ ...TEST_MANIFEST, name: "other-widget" },
|
|
174
|
+
"main.tsx",
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const widgets = await store.loadAll();
|
|
178
|
+
expect(widgets).toHaveLength(2);
|
|
179
|
+
expect(widgets.map((w) => w.manifest.name)).toContain("test-widget");
|
|
180
|
+
expect(widgets.map((w) => w.manifest.name)).toContain("other-widget");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns empty array when no widgets stored", async () => {
|
|
184
|
+
const widgets = await store.loadAll();
|
|
185
|
+
expect(widgets).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|