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,
}) => {