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"); }); });