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.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent b6f42bcddc
commit 7be9b5ace8
2 changed files with 129 additions and 0 deletions
+16
View File
@@ -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",
},
})
+113
View File
@@ -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/)
})