From af842ac2874d0cf6548ffc685130b81bbffda98d Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 15:59:14 +0900 Subject: [PATCH] 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. --- packages/mcp-server/tests/e2e/shell.spec.ts | 77 ++++++++++ packages/mcp-server/tests/http-server.test.ts | 65 +++++++++ packages/mcp-server/tests/selection.test.ts | 136 ++++++++++++++++++ .../mcp-server/tests/server-wiring.test.ts | 45 +++++- tests/unit/mcp-sync-core.test.ts | 136 +++++++++++++++++- 5 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 packages/mcp-server/tests/selection.test.ts diff --git a/packages/mcp-server/tests/e2e/shell.spec.ts b/packages/mcp-server/tests/e2e/shell.spec.ts index 8e971a08..d4558f26 100644 --- a/packages/mcp-server/tests/e2e/shell.spec.ts +++ b/packages/mcp-server/tests/e2e/shell.spec.ts @@ -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: ``, + }) + 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) => { + 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`, + }) + 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 ({ diff --git a/packages/mcp-server/tests/http-server.test.ts b/packages/mcp-server/tests/http-server.test.ts index 8d1e0cb3..1ec04470 100644 --- a/packages/mcp-server/tests/http-server.test.ts +++ b/packages/mcp-server/tests/http-server.test.ts @@ -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, "x") + 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, "y") + 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("y") + }) + + it("ignores an answer to another request, and gives up in time", async () => { + const id = "mcp-selection-late" + setState(id, "x") + 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 = `` const getJson = async (id: string) => diff --git a/packages/mcp-server/tests/selection.test.ts b/packages/mcp-server/tests/selection.test.ts new file mode 100644 index 00000000..d24fc664 --- /dev/null +++ b/packages/mcp-server/tests/selection.test.ts @@ -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() + }) +}) diff --git a/packages/mcp-server/tests/server-wiring.test.ts b/packages/mcp-server/tests/server-wiring.test.ts index 18c139fe..c8dbeae1 100644 --- a/packages/mcp-server/tests/server-wiring.test.ts +++ b/packages/mcp-server/tests/server-wiring.test.ts @@ -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.", + ) + }) +}) diff --git a/tests/unit/mcp-sync-core.test.ts b/tests/unit/mcp-sync-core.test.ts index 7959d580..7f713b08 100644 --- a/tests/unit/mcp-sync-core.test.ts +++ b/tests/unit/mcp-sync-core.test.ts @@ -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 = {}) => + state("S1", 2, "A", { 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: "page 2 only", + }), + ) + 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: "page 2" }) + 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,