mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 12:39:52 +08:00
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:
@@ -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) =>
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user