Files
next-ai-draw-io/packages/mcp-server/tests/e2e/shell.spec.ts
T
dayuan.jiang af842ac287 test(mcp-server): selection requests, get_selection texts, and page screenshots that leave the view alone
Unit tests for the request plumbing (random id, one answer, timeout), the tool's texts (cells, nothing selected, external draw.io) and the shell's side (one answer per request, after draw.io is up, never during a projection); the wiring test starts a session with BROWSER=none to see the no-tab message. Shell e2e: cells selected in the editor reach get_selection; a PNG of another page differs from the page on screen, which stays.
2026-10-11 20:56:07 +09:00

488 lines
19 KiB
TypeScript

/**
* The canvas shell as start_session opens it with DRAWIO_PREVIEW_UI=shell:
* the built server (dist/index.js, or the copy MCP_SERVER_ENTRY names: CI
* installs the packed tarball in an empty directory) runs over stdio as an
* MCP client would start it, and headless Chromium opens the URL it returns.
*/
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, type Page, test } from "@playwright/test"
const pkg = join(dirname(fileURLToPath(import.meta.url)), "../..")
let client: Client
let dataDir: string
let shellUrl: string
test.beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), "mcp-shell-e2e-"))
const transport = new StdioClientTransport({
command: process.execPath,
args: [process.env.MCP_SERVER_ENTRY ?? join(pkg, "dist/index.js")],
env: {
...process.env,
DRAWIO_DATA_DIR: dataDir,
DRAWIO_PREVIEW_UI: "shell",
// Away from the web app's dev server and other test runs
PORT: String(6100 + Math.floor(Math.random() * 400)),
BROWSER: "none",
},
stderr: "pipe",
})
client = new Client({ name: "shell-e2e", version: "0.0.1" })
await client.connect(transport)
const result = await client.callTool({
name: "start_session",
arguments: {},
})
const text = (result.content as { type: string; text?: string }[])
.map((c) => c.text ?? "")
.join("\n")
const match = text.match(/Browser URL: (\S+)/)
if (!match) throw new Error(`start_session gave no URL:\n${text}`)
shellUrl = match[1]
})
test.afterAll(async () => {
await client?.close()
rmSync(dataDir, { recursive: true, force: true })
})
test("start_session opens the shell, which loads the bundled draw.io", async ({
page,
}) => {
expect(shellUrl).toMatch(/^http:\/\/localhost:\d+\/shell\/\?mcp=mcp-/)
const errors: string[] = []
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text())
})
page.on("pageerror", (error) => errors.push(error.message))
await page.goto(shellUrl)
const iframe = page.locator("iframe[title='draw.io']")
await expect(iframe).toHaveAttribute(
"src",
/^http:\/\/localhost:\d+\/drawio\/index\.html\?embed=1&proto=json&configure=1&/,
)
// The cover goes once draw.io sent "init" and loaded the first document
await expect(page.getByTestId("canvas-loading")).toHaveCount(0, {
timeout: 60_000,
})
const frame = page.frameLocator("iframe[title='draw.io']")
await expect(frame.locator(".geDiagramContainer")).toBeVisible()
// The shell's own texts and state: the first poll connects it
await expect(page.getByTestId("shell-status")).toHaveText(
"Connected to the MCP server",
)
await expect(page.getByTestId("shell-session")).toHaveText(
shellUrl.slice(-8),
)
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) =>
`<mxCell id="${id}" value="${label}" x="${x}" y="40" w="120" h="60"/>`
const toolText = async (name: string, args: Record<string, unknown> = {}) => {
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<mxCell id="4" source="2" target="3"/>`,
})
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: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="s1" value="OnSecond" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
})
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("a screenshot of another page leaves the page on screen alone", async ({
page,
}) => {
const frame = await openShell(page)
await toolText("create_new_diagram", { xml: box("2", "Front page", 40) })
await expect(frame.getByText("Front page")).toBeVisible({ timeout: 10_000 })
await toolText("add_page", {
name: "Backside",
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="b1" value="OnBack" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
})
const tabs = frame.locator(".geTabContainer")
await expect(tabs.getByText("Backside", { exact: true })).toBeVisible({
timeout: 10_000,
})
const activeTab = tabs.locator(".geActivePage")
await expect(activeTab).toHaveText("Page-1")
// The page on screen, as the editor reports it to the server
const before = await toolText("get_selection")
expect(before).toContain('page "Page-1"')
// PNG: draw.io renders the other page by its id, off screen; the shell
// never shows it as a projection, so the page on screen does not change
const png = async (args: Record<string, unknown>) => {
const result = await client.callTool({
name: "screenshot_diagram",
arguments: args,
})
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)
return image?.data ?? ""
}
const back = await png({ page_name: "Backside" })
await expect(activeTab).toHaveText("Page-1")
await expect(frame.getByText("Front page")).toBeVisible()
expect(await toolText("get_selection")).toContain('page "Page-1"')
// Another picture than the page on screen
expect(await png({})).not.toBe(back)
})
test("get_selection reports the cells selected in the editor", async ({
page,
}) => {
const frame = await openShell(page)
await toolText("create_new_diagram", {
xml: `${box("2", "Pick me", 40)}\n${box("3", "Leave me", 240)}\n<mxCell id="4" value="link" source="2" target="3"/>`,
})
await expect(frame.getByText("Pick me")).toBeVisible({ timeout: 10_000 })
const nothing = await toolText("get_selection")
expect(nothing).toContain("Nothing is selected")
expect(nothing).toContain('page "Page-1"')
// Select a shape and the edge in the editor, as a click would
await frame.locator("body").evaluate((body) => {
const w = body.ownerDocument.defaultView as any
w.Draw.loadPlugin((ui: any) => {
const graph = ui.editor.graph
graph.setSelectionCells([
graph.model.getCell("2"),
graph.model.getCell("4"),
])
})
})
const text = await toolText("get_selection")
expect(text).toContain('2 cells selected on page "Page-1"')
expect(text).toContain('id="2" shape "Pick me" at x=40 y=40 w=120 h=60')
expect(text).toContain('id="4" edge "link" from "2" to "3"')
expect(text).not.toContain('id="3"')
})
// --- Version cards from the server's History (shell/node-versions-source.ts) ---
test("the version cards follow the server's History; undo, redo and restore go through it", async ({
page,
}) => {
// A session of its own, so History holds only this test's versions
const started = await toolText("start_session")
const url = started.match(/Browser URL: (\S+)/)?.[1]
if (!url) throw new Error(`start_session gave no URL:\n${started}`)
await page.goto(url)
await expect(page.getByTestId("canvas-loading")).toHaveCount(0, {
timeout: 60_000,
})
await expect(page.getByTestId("shell-status")).toHaveAttribute(
"data-status",
"connected",
)
const frame = page.frameLocator("iframe[title='draw.io']")
const cards = page.getByTestId("version-card")
const latestCard = cards.last()
const getDiagram = () => toolText("get_diagram")
// Three writes, three cards; the newest can be undone
await toolText("create_new_diagram", { xml: box("a", "Card A", 40) })
await expect(frame.getByText("Card A")).toBeVisible({ timeout: 10_000 })
await expect(cards).toHaveCount(1, { timeout: 10_000 })
await expect(cards.first()).toContainText("Drew the diagram")
await toolText("edit_diagram", {
operations: [
{
operation: "add",
cell_id: "b",
new_xml: box("b", "Card B", 240),
},
],
})
await expect(frame.getByText("Card B")).toBeVisible({ timeout: 10_000 })
await toolText("edit_diagram", {
operations: [
{
operation: "add",
cell_id: "c",
new_xml: box("c", "Card C", 440),
},
],
})
await expect(frame.getByText("Card C")).toBeVisible({ timeout: 10_000 })
await expect(cards).toHaveCount(3, { timeout: 10_000 })
await expect(latestCard).toContainText("Changed the diagram")
await expect(latestCard.getByTestId("version-undo")).toHaveText(
/Undo change/,
)
await expect(page.getByTestId("version-thumb")).toHaveCount(3)
// Undo: the server restores the version before; the card says so
await latestCard.getByTestId("version-undo").click()
await expect(frame.getByText("Card C")).toHaveCount(0, { timeout: 10_000 })
await expect(frame.getByText("Card B")).toBeVisible()
await expect(latestCard).toContainText("Change undone")
await expect(latestCard.getByTestId("version-undo")).toHaveText(
/Redo change/,
)
// A restore adds a History entry, not a card
await expect(cards).toHaveCount(3)
await expect
.poll(async () => (await getDiagram()).includes('id="c"'), {
timeout: 10_000,
})
.toBe(false)
// Redo: the newest version is back on the canvas and the server
await latestCard.getByTestId("version-undo").click()
await expect(frame.getByText("Card C")).toBeVisible({ timeout: 10_000 })
await expect(latestCard).toContainText("Changed the diagram")
await expect(latestCard.getByTestId("version-undo")).toHaveText(
/Undo change/,
)
await expect
.poll(async () => (await getDiagram()).includes('id="c"'), {
timeout: 10_000,
})
.toBe(true)
// A hand edit: no version is on the canvas, so the newest card offers
// a restore, not an undo
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-card",
"By hand",
40,
200,
120,
60,
"rounded=1;",
)
} finally {
graph.getModel().endUpdate()
}
})
})
await expect
.poll(async () => (await getDiagram()).includes('id="manual-card"'), {
timeout: 10_000,
})
.toBe(true)
await expect(latestCard.getByTestId("version-restore")).toBeVisible()
await expect(cards).toHaveCount(3)
// Restoring the first version keeps the hand edit as a version of its
// own, and get_diagram agrees with the canvas
await cards.first().getByTestId("version-restore").click()
await expect(frame.getByText("Card B")).toHaveCount(0, { timeout: 10_000 })
await expect(frame.getByText("By hand")).toHaveCount(0)
await expect(frame.getByText("Card A")).toBeVisible()
await expect(cards).toHaveCount(4, { timeout: 10_000 })
await expect(cards.nth(3)).toContainText("Changed by hand")
// The first version is on the canvas: its restore button rests
await expect(cards.first().getByTestId("version-restore")).toBeDisabled()
await expect
.poll(getDiagram, { timeout: 10_000 })
.toContain('value="Card A"')
const xml = await getDiagram()
expect(xml).not.toContain('id="b"')
expect(xml).not.toContain('id="manual-card"')
// The MCP tool sees the same versions: the hand edit is one step back
expect(await toolText("restore_version", { steps_back: 1 })).toContain(
"Restored the version 1 step(s) back",
)
await expect(frame.getByText("By hand")).toBeVisible({ timeout: 10_000 })
await expect(cards).toHaveCount(4)
})
test("the theme toggle switches the page and draw.io, and is remembered", async ({
page,
}) => {
await page.goto(shellUrl)
await expect(page.getByTestId("canvas-loading")).toHaveCount(0, {
timeout: 60_000,
})
const html = page.locator("html")
const frameBody = page
.frameLocator("iframe[title='draw.io']")
.locator("body")
await expect(html).not.toHaveClass(/dark/)
await expect(frameBody).not.toHaveClass(/geDarkMode/)
await page.getByTestId("theme-toggle").click()
await expect(html).toHaveClass(/dark/)
// Same-origin draw.io switches in place, without a reload
await expect(frameBody).toHaveClass(/geDarkMode/)
expect(
await page.evaluate(() => localStorage.getItem("mcp:dark-mode")),
).toBe("true")
await page.reload()
await expect(html).toHaveClass(/dark/)
await page.getByTestId("theme-toggle").click()
await expect(html).not.toHaveClass(/dark/)
})