Files
excalidraw-box/src/mcp/server.test.ts

139 lines
4.4 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type ScenePayload } from "../core/shared";
import { createDrawingStore, type DrawingStore } from "../server/db";
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./server";
const cleanup: Array<{ dir: string; store?: DrawingStore }> = [];
afterEach(async () => {
while (cleanup.length > 0) {
const item = cleanup.pop();
if (item) {
item.store?.close();
rmSync(item.dir, { recursive: true, force: true });
}
}
});
function createTempTools() {
const dir = mkdtempSync(join(tmpdir(), "excali-mcp-"));
const store = createDrawingStore(join(dir, "test.sqlite"));
cleanup.push({ dir, store });
return {
store,
tools: createMcpToolHandlers(store, "http://example.test"),
};
}
function testScene(): ScenePayload {
return {
elements: [
{ id: "one", type: "rectangle", x: 10, y: 20 },
{ id: "two", type: "text", text: "hello" },
],
appState: { theme: "dark" },
files: {},
};
}
describe("mcp tool handlers", () => {
test("PUBLIC_BASE_URL is required", () => {
expect(() => resolvePublicBaseUrl("")).toThrow("PUBLIC_BASE_URL is required");
});
test("create_drawing writes an explicit scene to a temp SQLite DB", () => {
const { store, tools } = createTempTools();
const scene = testScene();
const result = tools.create_drawing({ title: "explicit", scene });
const drawing = store.getDrawing(result.id);
expect(result).toEqual({
id: result.id,
title: "explicit",
revision: 1,
url: `http://example.test/d/${result.id}`,
});
expect(drawing?.revision).toBe(1);
expect(drawing?.scene).toEqual(scene);
});
test("get_drawing returns raw scene plus URL", () => {
const { tools } = createTempTools();
const scene = testScene();
const result = tools.create_drawing({ title: "read", scene });
const drawing = tools.get_drawing({ id: result.id });
expect(drawing).toMatchObject({
id: result.id,
title: "read",
revision: 1,
url: `http://example.test/d/${result.id}`,
scene,
});
});
test("replace_drawing updates the existing drawing", () => {
const { store, tools } = createTempTools();
const result = tools.create_drawing({ title: "replace", scene: testScene() });
const nextScene: ScenePayload = {
elements: [{ id: "manual", type: "rectangle" }],
appState: { theme: "dark" },
files: {},
};
const replaced = tools.replace_drawing({ id: result.id, title: "replaced", scene: nextScene });
expect(store.listDrawings()).toHaveLength(1);
expect(replaced.revision).toBe(2);
expect(store.getDrawing(result.id)?.title).toBe("replaced");
expect(store.getDrawing(result.id)?.revision).toBe(2);
expect(store.getDrawing(result.id)?.scene).toEqual(nextScene);
});
test("replace_drawing keeps the revision when the scene is already stored", () => {
const { store, tools } = createTempTools();
const scene = testScene();
const result = tools.create_drawing({ title: "replace noop", scene });
const replaced = tools.replace_drawing({ id: result.id, scene });
expect(replaced.revision).toBe(1);
expect(store.getDrawing(result.id)?.revision).toBe(1);
expect(store.getDrawing(result.id)?.scene).toEqual(scene);
});
test("patch_drawing replaces an element property and removes an element", () => {
const { store, tools } = createTempTools();
const result = tools.create_drawing({ title: "patch", scene: testScene() });
const patched = tools.patch_drawing({
id: result.id,
patch: [
{ op: "replace", path: "/elements/0/x", value: 42 },
{ op: "remove", path: "/elements/1" },
],
});
expect(patched.revision).toBe(2);
expect(store.getDrawing(result.id)?.revision).toBe(2);
expect(store.getDrawing(result.id)?.scene.elements).toEqual([{ id: "one", type: "rectangle", x: 42, y: 20 }]);
});
test("missing drawing IDs return tool errors", () => {
const { tools } = createTempTools();
expect(() => tools.get_drawing({ id: "missing" })).toThrow("Drawing not found: missing");
expect(() =>
tools.replace_drawing({
id: "missing",
scene: testScene(),
}),
).toThrow("Drawing not found: missing");
});
});