From 7be9b5ace8e597070e85b2b1b8eb44e1bb9efe5f Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 13:04:48 +0900 Subject: [PATCH] test(mcp-server): open the built shell with playwright npm run test:e2e starts dist/index.js over the MCP SDK with DRAWIO_PREVIEW_UI=shell, opens the URL start_session returns in headless Chromium, and checks that the bundled draw.io loads without console errors and that the theme toggle switches draw.io in place. --- packages/mcp-server/playwright.config.ts | 16 +++ packages/mcp-server/tests/e2e/shell.spec.ts | 113 ++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 packages/mcp-server/playwright.config.ts create mode 100644 packages/mcp-server/tests/e2e/shell.spec.ts diff --git a/packages/mcp-server/playwright.config.ts b/packages/mcp-server/playwright.config.ts new file mode 100644 index 00000000..59dfba86 --- /dev/null +++ b/packages/mcp-server/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "@playwright/test" + +// Browser tests of the built package (npm run build first): each spec +// starts the MCP server itself over stdio, so there is no web server here. +// Playwright comes from the repository root (npm ci there). +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 90_000, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: "list", + use: { + browserName: "chromium", + trace: "retain-on-failure", + }, +}) diff --git a/packages/mcp-server/tests/e2e/shell.spec.ts b/packages/mcp-server/tests/e2e/shell.spec.ts new file mode 100644 index 00000000..1e446104 --- /dev/null +++ b/packages/mcp-server/tests/e2e/shell.spec.ts @@ -0,0 +1,113 @@ +/** + * The canvas shell as start_session opens it with DRAWIO_PREVIEW_UI=shell: + * 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 { 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" + +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: [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 + await expect(page.getByTestId("shell-status")).toHaveText( + "Waiting for the MCP server…", + ) + await expect(page.getByTestId("shell-session")).toHaveText( + shellUrl.slice(-8), + ) + expect(errors).toEqual([]) +}) + +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/) +})