From 5ed8940b48d5af1a184c5023f16424e5a8a86397 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 13:44:57 +0900 Subject: [PATCH] test(mcp-server): e2e of the shell's sync with the server The shell connects, shows what create_new_diagram draws, marks an edit_diagram change and takes it back with one Ctrl+Z (which get_diagram then reflects), pushes a shape inserted in the editor, serves screenshot_diagram, and shows another page only for its SVG export before the user's page comes back. --- packages/mcp-server/tests/e2e/shell.spec.ts | 166 +++++++++++++++++++- 1 file changed, 162 insertions(+), 4 deletions(-) diff --git a/packages/mcp-server/tests/e2e/shell.spec.ts b/packages/mcp-server/tests/e2e/shell.spec.ts index 1e446104..ad4e70b9 100644 --- a/packages/mcp-server/tests/e2e/shell.spec.ts +++ b/packages/mcp-server/tests/e2e/shell.spec.ts @@ -3,13 +3,13 @@ * the built server (dist/index.js) runs over stdio as an MCP client would * start it, and headless Chromium opens the URL it returns. */ -import { mkdtempSync, rmSync } from "node:fs" +import { mkdtempSync, readFileSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" -import { expect, test } from "@playwright/test" +import { expect, type Page, test } from "@playwright/test" const pkg = join(dirname(fileURLToPath(import.meta.url)), "../..") @@ -74,9 +74,9 @@ test("start_session opens the shell, which loads the bundled draw.io", async ({ const frame = page.frameLocator("iframe[title='draw.io']") await expect(frame.locator(".geDiagramContainer")).toBeVisible() - // The shell's own texts and state + // The shell's own texts and state: the first poll connects it await expect(page.getByTestId("shell-status")).toHaveText( - "Waiting for the MCP server…", + "Connected to the MCP server", ) await expect(page.getByTestId("shell-session")).toHaveText( shellUrl.slice(-8), @@ -84,6 +84,164 @@ test("start_session opens the shell, which loads the bundled draw.io", async ({ expect(errors).toEqual([]) }) +// --- The sync with the server (shell/use-mcp-sync.ts) --- + +// Each test draws labels of its own: the previous test's diagram is on the +// canvas when a test starts, and a label shared with it would be visible +// before the test's own write arrives +const box = (id: string, label: string, x: number) => + `` + +const toolText = async (name: string, args: Record = {}) => { + const result = await client.callTool({ name, arguments: args }) + return (result.content as { type: string; text?: string }[]) + .map((c) => c.text ?? "") + .join("\n") +} + +/** The shell with the bundled draw.io ready and in step with the server */ +async function openShell(page: Page) { + await page.goto(shellUrl) + await expect(page.getByTestId("canvas-loading")).toHaveCount(0, { + timeout: 60_000, + }) + await expect(page.getByTestId("shell-status")).toHaveAttribute( + "data-status", + "connected", + ) + return page.frameLocator("iframe[title='draw.io']") +} + +test("a diagram the MCP draws appears in the shell", async ({ page }) => { + const frame = await openShell(page) + await toolText("create_new_diagram", { + xml: `${box("2", "Alpha", 40)}\n${box("3", "Beta", 240)}\n`, + }) + await expect(frame.getByText("Alpha")).toBeVisible({ timeout: 10_000 }) + await expect(frame.getByText("Beta")).toBeVisible() +}) + +test("an edit is marked on the canvas, and one Ctrl+Z takes it back for the server too", async ({ + page, +}) => { + const frame = await openShell(page) + await toolText("create_new_diagram", { + xml: `${box("2", "Node A", 40)}\n${box("3", "Node B", 240)}`, + }) + await expect(frame.getByText("Node A")).toBeVisible({ timeout: 10_000 }) + await toolText("edit_diagram", { + operations: [ + { operation: "add", cell_id: "5", new_xml: box("5", "Gamma", 440) }, + ], + }) + await expect(frame.getByText("Gamma")).toBeVisible({ timeout: 10_000 }) + // The editor bridge outlines the changed shape (editor-bridge.ts + // HIGHLIGHT_OUTLINE); the write went on the canvas in place + await expect(frame.locator('[stroke="#a86b00"]')).toHaveCount(1) + + await frame + .locator(".geDiagramContainer") + .click({ position: { x: 10, y: 10 } }) + await page.keyboard.press("Control+z") + await expect(frame.getByText("Gamma")).toHaveCount(0) + await expect(frame.getByText("Node A")).toBeVisible() + // The undo is pushed: the server's diagram has no Gamma (get_diagram + // also names the removed cell in its notes, so look at the XML) + await expect + .poll(async () => (await toolText("get_diagram")).includes('id="5"'), { + timeout: 10_000, + }) + .toBe(false) + expect(await toolText("get_diagram")).toContain('value="Node A"') +}) + +test("a change made in the editor is pushed, and get_diagram returns it", async ({ + page, +}) => { + const frame = await openShell(page) + await toolText("create_new_diagram", { xml: box("2", "Solo", 40) }) + await expect(frame.getByText("Solo")).toBeVisible({ timeout: 10_000 }) + // draw.io keeps no global editor; in embed mode Draw.loadPlugin hands + // it to the callback at once + await frame.locator("body").evaluate((body) => { + const w = body.ownerDocument.defaultView as any + w.Draw.loadPlugin((ui: any) => { + const graph = ui.editor.graph + graph.getModel().beginUpdate() + try { + graph.insertVertex( + graph.getDefaultParent(), + "manual-1", + "Manual", + 40, + 200, + 120, + 60, + "rounded=1;", + ) + } finally { + graph.getModel().endUpdate() + } + }) + }) + await expect(frame.getByText("Manual")).toBeVisible() + await expect + .poll( + async () => + (await toolText("get_diagram")).includes('id="manual-1"'), + { timeout: 10_000 }, + ) + .toBe(true) +}) + +test("screenshot_diagram renders the canvas of the shell", async ({ page }) => { + const frame = await openShell(page) + await toolText("create_new_diagram", { + xml: `${box("2", "Shot A", 40)}\n${box("3", "Shot B", 240)}`, + }) + await expect(frame.getByText("Shot B")).toBeVisible({ timeout: 10_000 }) + const result = await client.callTool({ + name: "screenshot_diagram", + arguments: {}, + }) + const image = ( + result.content as { type: string; data?: string; mimeType?: string }[] + ).find((c) => c.type === "image") + expect(result.isError ?? false).toBe(false) + expect(image?.mimeType).toBe("image/png") + expect(image?.data?.length ?? 0).toBeGreaterThan(1000) +}) + +test("exporting another page shows it for a moment, then the user's page comes back", async ({ + page, +}) => { + const frame = await openShell(page) + await toolText("create_new_diagram", { xml: box("2", "First page", 40) }) + await expect(frame.getByText("First page")).toBeVisible({ timeout: 10_000 }) + await toolText("add_page", { + name: "Second", + xml: ``, + }) + await expect(frame.getByText("Second")).toBeVisible({ timeout: 10_000 }) + // SVG has no page option in draw.io: the server sends a one-page + // projection, which the shell shows only for the export + const file = join(dataDir, "second.svg") + const text = await toolText("export_diagram", { + path: file, + page_name: "Second", + }) + expect(text).toContain("exported successfully") + expect(readFileSync(file, "utf8")).toContain("OnSecond") + await expect(frame.getByText("First page")).toBeVisible({ timeout: 10_000 }) + await expect(frame.locator(".geTabContainer .geActivePage")).toHaveText( + "Page-1", + ) + // The projection was never pushed as the document + const xml = await toolText("get_diagram") + expect(xml).toContain('value="First page"') + expect(xml).toContain('name="Second"') +}) + test("the theme toggle switches the page and draw.io, and is remembered", async ({ page, }) => {