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,