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.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent b3dcd91f54
commit af842ac287
5 changed files with 455 additions and 4 deletions
@@ -243,6 +243,83 @@ test("exporting another page shows it for a moment, then the user's page comes b
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 ({
@@ -29,6 +29,7 @@ import {
onStateChange,
previewUrl,
requestExport,
requestSelection,
requestSync,
restoreHistoryEntry,
setDrawioDir,
@@ -37,6 +38,7 @@ import {
shellConfig,
shutdown,
startHttpServer,
waitForSelection,
waitForSync,
} from "../src/http-server.ts"
@@ -424,6 +426,69 @@ describe("export requests", () => {
})
})
describe("selection requests (get_selection)", () => {
const picked = {
pageId: "p1",
pageName: "Page-1",
cells: [{ id: "3", label: "Login", edge: false }],
}
it("shows the request to the page and takes its answer once", async () => {
const id = "mcp-selection"
setState(id, "<mxfile>x</mxfile>")
expect(requestSelection(id)).toBe(true)
const poll = JSON.parse(
(await request(`/api/state?sessionId=${id}`)).body,
)
expect(poll.selectionId).toMatch(/^[0-9a-f-]{36}$/)
// The request survives a write meanwhile
setState(id, "<mxfile>y</mxfile>")
const waiting = waitForSelection(id, 3000)
const answer = await postJson("/api/state", {
sessionId: id,
selectionId: poll.selectionId,
selection: picked,
})
expect(answer.status).toBe(200)
expect(await waiting).toEqual(picked)
// Taken: the next poll shows no request
const after = JSON.parse(
(await request(`/api/state?sessionId=${id}`)).body,
)
expect(after.selectionId).toBeNull()
expect(getState(id)?.xml).toBe("<mxfile>y</mxfile>")
})
it("ignores an answer to another request, and gives up in time", async () => {
const id = "mcp-selection-late"
setState(id, "<mxfile>x</mxfile>")
requestSelection(id)
const first = JSON.parse(
(await request(`/api/state?sessionId=${id}`)).body,
)
requestSelection(id)
await postJson("/api/state", {
sessionId: id,
selectionId: first.selectionId,
selection: picked,
})
expect(getState(id)?.selection).toBeUndefined()
expect(getState(id)?.selectionId).not.toBe(first.selectionId)
// A malformed answer is not taken either
const second = getState(id)?.selectionId
await postJson("/api/state", {
sessionId: id,
selectionId: second,
selection: { cells: "3" },
})
expect(getState(id)?.selection).toBeUndefined()
expect(await waitForSelection(id, 250)).toBeNull()
// The request is withdrawn with the timeout
expect(getState(id)?.selectionId).toBeUndefined()
expect(requestSelection("mcp-unknown-session")).toBe(false)
})
})
describe("a session state recreated after it was lost", () => {
const SAVED = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="saved" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
const getJson = async (id: string) =>
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest"
import {
describeSelection,
parseSelectionAnswer,
type SelectionAnswer,
} from "../src/selection.ts"
const page = { pageId: "p1", pageName: "Flow" }
describe("get_selection text", () => {
it("lists shapes with their geometry and edges with their ends", () => {
const text = describeSelection(
{
...page,
cells: [
{
id: "3",
label: "Login",
edge: false,
geometry: { x: 100, y: 100, width: 120, height: 60 },
},
{
id: "7",
label: "",
edge: false,
geometry: { x: 20, y: 30, width: 80, height: 40 },
parent: "box",
},
{
id: "5",
label: "yes",
edge: true,
source: "3",
target: "4",
},
{ id: "9", label: "", edge: true },
],
},
null,
)
expect(text).toContain(
'4 cells selected on page "Flow" (page_id="p1"):',
)
expect(text).toContain(
'- id="3" shape "Login" at x=100 y=100 w=120 h=60',
)
expect(text).toContain(
'- id="7" shape (no label) at x=20 y=30 w=80 h=40 inside "box"',
)
expect(text).toContain('- id="5" edge "yes" from "3" to "4"')
expect(text).toContain('- id="9" edge (no label) (not connected)')
expect(text).toContain('edit_diagram (page_id="p1")')
})
it("says clearly when nothing is selected", () => {
const text = describeSelection({ ...page, cells: [] }, null)
expect(text).toBe(
'Nothing is selected on page "Flow" (page_id="p1"). Ask the user to select the shapes they mean in the preview, or call get_diagram.',
)
expect(
describeSelection(
{ pageId: null, pageName: null, cells: [] },
null,
),
).toContain("Nothing is selected on the page on screen.")
})
it("names the external draw.io when the page cannot read the editor", () => {
const unavailable: SelectionAnswer = {
pageId: null,
pageName: null,
cells: [],
unavailable: true,
}
expect(
describeSelection(unavailable, "https://embed.diagrams.net"),
).toBe(
"The preview loads draw.io from https://embed.diagrams.net (another origin), where the selection cannot be read. Ask the user which shapes they mean, or call get_diagram.",
)
// Same origin, but the bridge did not attach (a draw.io change)
expect(describeSelection(unavailable, null)).toContain(
"could not read the editor's selection",
)
})
})
describe("the page's selection answer", () => {
it("takes the fields it knows and drops malformed cells", () => {
expect(
parseSelectionAnswer({
pageId: "p1",
pageName: "Flow",
cells: [
{
id: "3",
label: "Login",
edge: false,
geometry: { x: 1, y: 2, width: 3, height: 4 },
parent: "box",
style: "ignored",
},
{ id: "5", edge: true, source: "3", target: 4 },
{ id: "", label: "no id" },
"not a cell",
{ id: "8", geometry: { x: "1", y: 2 } },
],
}),
).toEqual({
pageId: "p1",
pageName: "Flow",
cells: [
{
id: "3",
label: "Login",
edge: false,
geometry: { x: 1, y: 2, width: 3, height: 4 },
parent: "box",
},
{ id: "5", label: "", edge: true, source: "3" },
{ id: "8", label: "", edge: false },
],
})
})
it("keeps the unavailable mark and refuses other shapes", () => {
expect(parseSelectionAnswer({ unavailable: true })).toEqual({
pageId: null,
pageName: null,
cells: [],
unavailable: true,
})
expect(parseSelectionAnswer({ cells: "3,4" })).toBeNull()
expect(parseSelectionAnswer("3")).toBeNull()
expect(parseSelectionAnswer(null)).toBeNull()
})
})
@@ -9,8 +9,9 @@
* package before testing). Running from source via tsx means it executes as
* part of the normal `vitest run`.
*
* We deliberately do NOT call start_session — it would open a real browser
* window via open(). The browser bridge is covered by the Playwright e2e suite.
* start_session is called only at the end, with BROWSER=none so no browser
* window opens, for the tools that need a session but no preview tab. The
* browser bridge is covered by the Playwright e2e suite.
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
@@ -44,6 +45,7 @@ const EXPECTED_TOOLS = [
"get_drawing_guide",
"get_shape_library",
"screenshot_diagram",
"get_selection",
"list_saved_diagrams",
"restore_version",
]
@@ -89,7 +91,15 @@ beforeAll(async () => {
writeFileSync(path.join(dataDir, `${SAVED_ID}.drawio`), SAVED_DIAGRAM)
proc = spawn(tsxBin, [entry], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, DRAWIO_DATA_DIR: dataDir },
env: {
...process.env,
DRAWIO_DATA_DIR: dataDir,
// For the session tests at the end: no browser window, the
// canvas shell as the preview, a port away from other servers
BROWSER: "none",
DRAWIO_PREVIEW_UI: "shell",
PORT: String(6500 + Math.floor(Math.random() * 400)),
},
}) as ChildProcessWithoutNullStreams
proc.stdout.on("data", (chunk: Buffer) => {
@@ -215,6 +225,15 @@ describe("MCP server wiring", () => {
expect(resp.result.content[0].text).toContain("start_session")
})
it("refuses get_selection without a session", async () => {
const resp = await send("tools/call", {
name: "get_selection",
arguments: {},
})
expect(resp.result.isError).toBe(true)
expect(resp.result.content[0].text).toContain("start_session")
})
it("keeps every description and the instructions within the host limit", async () => {
const resp = await send("tools/list", {})
for (const tool of resp.result.tools) {
@@ -336,3 +355,23 @@ describe("load_diagram dual-source arguments", () => {
expect(resp.result?.content?.[0]?.text).toContain("No active session")
})
})
// After the tests above: start_session makes a session current
describe("MCP server wiring with a session, without a preview tab", () => {
it("get_selection says the preview tab is not connected", async () => {
const started = await send("tools/call", {
name: "start_session",
arguments: {},
})
expect(started.result.isError).toBeFalsy()
expect(started.result.content[0].text).toContain("Browser URL:")
const resp = await send("tools/call", {
name: "get_selection",
arguments: {},
})
expect(resp.result.isError).toBe(true)
expect(resp.result.content[0].text).toBe(
"Error: The preview tab is not open, or has not connected yet.",
)
})
})
+135 -1
View File
@@ -8,6 +8,7 @@ import {
type SyncStatus,
tokenFromPage,
} from "@/packages/mcp-server/shell/mcp-sync-core"
import type { SelectionAnswer } from "@/packages/mcp-server/src/selection"
// The shell's side of the MCP server protocol, driven step by step with a
// stubbed server (fetch) and canvas, as tests/unit/mcp-preview-recovery.test.ts
@@ -59,7 +60,15 @@ function open(options: { currentPageId?: string | null } = {}) {
const notices: SyncNotice[] = []
const statuses: SyncStatus[] = []
let recreated = 0
const canvas = { xml: "", pageId: options.currentPageId ?? null }
const canvas: {
xml: string
pageId: string | null
selection: SelectionAnswer
} = {
xml: "",
pageId: options.currentPageId ?? null,
selection: { pageId: "p1", pageName: "Page-1", cells: [] },
}
const sync = createMcpSync({
sessionId: "mcp-test",
apiBase: "/api",
@@ -86,6 +95,7 @@ function open(options: { currentPageId?: string | null } = {}) {
}),
currentXml: () => canvas.xml,
currentPageId: () => canvas.pageId,
readSelection: () => canvas.selection,
},
onNotice: (notice) => notices.push(notice),
onStatus: (status) => statuses.push(status),
@@ -863,6 +873,130 @@ describe("MCP sync export requests", () => {
})
})
describe("MCP sync selection requests (get_selection)", () => {
const selectionState = (extra: Record<string, unknown> = {}) =>
state("S1", 2, "<mxfile>A</mxfile>", { selectionId: "sel-1", ...extra })
const picked: SelectionAnswer = {
pageId: "p1",
pageName: "Page-1",
cells: [
{
id: "3",
label: "Login",
edge: false,
geometry: { x: 100, y: 100, width: 120, height: 60 },
},
{ id: "5", label: "", edge: true, source: "3", target: "4" },
],
}
it("answers a request once, with what the editor has selected", async () => {
const t = await inStep()
t.canvas.selection = picked
const poll = t.sync.poll()
t.next("GET").answer(selectionState())
await poll
const answer = t.next("POST")
expect(answer.body).toEqual({
sessionId: "mcp-test",
selectionId: "sel-1",
selection: picked,
})
answer.answer({ status: 200, body: { success: true } })
// The server still shows the request on the next poll: not again
const again = t.sync.poll()
t.next("GET").answer(selectionState())
await again
expect(t.posts()).toHaveLength(0)
// A new request is answered
t.canvas.selection = { pageId: "p1", pageName: "Page-1", cells: [] }
const later = t.sync.poll()
t.next("GET").answer(selectionState({ selectionId: "sel-2" }))
await later
expect(t.next("POST").body).toMatchObject({
selectionId: "sel-2",
selection: { cells: [] },
})
})
it("says when the page cannot reach the editor", async () => {
const t = await inStep()
t.canvas.selection = {
pageId: null,
pageName: null,
cells: [],
unavailable: true,
}
const poll = t.sync.poll()
t.next("GET").answer(selectionState())
await poll
expect(t.next("POST").body.selection).toEqual({
pageId: null,
pageName: null,
cells: [],
unavailable: true,
})
})
it("waits until draw.io is up, where the editor is reached", async () => {
const t = open()
t.canvas.selection = picked
let poll = t.sync.poll()
t.next("GET").answer(selectionState())
await poll
expect(t.posts()).toHaveLength(0)
t.sync.setReady(true)
// The thumbnail of the write loaded before draw.io was ready
t.nextExport().answer(null)
await t.settle()
poll = t.sync.poll()
t.next("GET").answer(selectionState())
await poll
expect(t.next("POST").body).toMatchObject({
selectionId: "sel-1",
selection: picked,
})
})
it("never answers while a projection is on screen", async () => {
const t = await inStep()
t.canvas.selection = picked
// A projection shows another page: its selection is not the user's
let poll = t.sync.poll()
t.next("GET").answer(
selectionState({
exportFormat: "svg",
exportId: "e1",
exportXml: "<mxfile>page 2 only</mxfile>",
}),
)
await poll
expect(t.sync.read().projectionActive).toBe(true)
expect(t.posts()).toHaveLength(0)
// The export answers and the document comes back
await vi.advanceTimersByTimeAsync(600)
t.nextExport().answer({ data: "<svg>page 2</svg>" })
await t.settle()
t.next("POST").answer({ status: 200, body: { success: true } })
await t.settle()
await t.settle()
t.next("GET").answer(selectionState())
await t.settle()
await t.settle()
expect(t.posts()).toHaveLength(0)
t.sync.onDrawioLoad()
expect(t.sync.read().projectionActive).toBe(false)
// The request is still pending: answered now
poll = t.sync.poll()
t.next("GET").answer(selectionState())
await poll
expect(t.next("POST").body).toMatchObject({
selectionId: "sel-1",
selection: picked,
})
})
})
describe("MCP sync History", () => {
const historyList = {
status: 200,