Files
next-ai-draw-io/tests/unit/mcp-sync-core.test.ts
T
dayuan.jiang af842ac287 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.
2026-10-11 20:56:07 +09:00

1524 lines
56 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
createMcpSync,
decideLoad,
type ExportRequest,
type LoadDecision,
type SyncNotice,
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
// drives the classic page. Timers are faked; settle() lets promises run.
// A string body is sent as it is (a page's HTML); anything else as JSON
type Answer = { status: number; body?: unknown }
interface Call {
url: string
method: string
headers: Record<string, string>
body: any
answer: (a: Answer) => void
fail: () => void
}
interface PendingExport {
request: ExportRequest
answer: (result: { data?: string; xml?: string } | null) => void
}
const realSetTimeout = globalThis.setTimeout
function open(options: { currentPageId?: string | null } = {}) {
const calls: Call[] = []
const fetchStub = vi.fn(
(url: string, init?: RequestInit) =>
new Promise<Response>((resolve, reject) => {
calls.push({
url,
method: init?.method ?? "GET",
headers: (init?.headers ?? {}) as Record<string, string>,
body: init?.body ? JSON.parse(String(init.body)) : null,
answer: ({ status, body }) =>
resolve(
new Response(
typeof body === "string"
? body
: JSON.stringify(body),
{ status },
),
),
fail: () => reject(new TypeError("Failed to fetch")),
})
}),
)
const loads: { xml: string; decision: LoadDecision }[] = []
const transients: string[] = []
const exports: PendingExport[] = []
const notices: SyncNotice[] = []
const statuses: SyncStatus[] = []
let recreated = 0
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",
token: "test-token",
fetch: fetchStub as unknown as typeof fetch,
canvas: {
load: (xml, decision) => {
loads.push({ xml, decision })
canvas.xml = xml
},
showTransient: (xml) => transients.push(xml),
export: (request, timeoutMs) =>
new Promise((resolve) => {
const pending = { request, answer: resolve }
exports.push(pending)
// As DiagramProvider.requestExport: null when draw.io
// does not answer in time
setTimeout(() => {
if (exports.includes(pending)) {
exports.splice(exports.indexOf(pending), 1)
resolve(null)
}
}, timeoutMs)
}),
currentXml: () => canvas.xml,
currentPageId: () => canvas.pageId,
readSelection: () => canvas.selection,
},
onNotice: (notice) => notices.push(notice),
onStatus: (status) => statuses.push(status),
onStateRecreated: () => recreated++,
})
const settle = () => new Promise((r) => realSetTimeout(r, 0))
const next = (method: string, prefix = "/api/") => {
const call = calls.find(
(c) => c.method === method && c.url.startsWith(prefix),
)
if (!call) throw new Error(`no pending ${method} ${prefix}`)
calls.splice(calls.indexOf(call), 1)
return call
}
const nextExport = () => {
const pending = exports.shift()
if (!pending) throw new Error("no pending export")
return pending
}
const posts = () => calls.filter((c) => c.method === "POST")
return {
sync,
calls,
loads,
transients,
exports,
notices,
statuses,
canvas,
recreated: () => recreated,
settle,
next,
nextExport,
posts,
}
}
const state = (
stateId: string,
version: number,
xml: string,
extra: Record<string, unknown> = {},
) => ({
status: 200,
body: {
stateId,
version,
xml,
blank: false,
syncRequested: false,
exportFormat: null,
exportXml: null,
exportOptions: null,
exportId: null,
...extra,
},
})
/** A tab in step with state S1 at version 2, showing diagram A, draw.io ready */
async function inStep(options: { currentPageId?: string | null } = {}) {
const t = open(options)
t.sync.setReady(true)
const poll = t.sync.poll()
t.next("GET").answer(state("S1", 2, "<mxfile>A</mxfile>"))
await poll
expect(t.sync.read().lastXml).toBe("<mxfile>A</mxfile>")
expect(t.loads).toHaveLength(1)
// The thumbnail of the loaded write is taken and sent
await vi.advanceTimersByTimeAsync(500)
t.nextExport().answer({ data: "<svg>A</svg>" })
await t.settle()
t.next("POST", "/api/history-svg").answer({ status: 200, body: {} })
await t.settle()
return t
}
/** The user edits to xml and draw.io answers the SVG export of it */
async function edit(t: Awaited<ReturnType<typeof inStep>>, xml: string) {
t.sync.onAutoSave(xml)
t.nextExport().answer({ data: "<svg/>" })
await t.settle()
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe("MCP sync after the server recreated its session", () => {
it("keeps edits made while the server was down", async () => {
const t = await inStep()
// The user edits; the server is gone
await edit(t, "<mxfile>B</mxfile>")
t.next("POST").fail()
await t.settle()
expect(t.notices).toEqual(["unreachable"])
expect(t.sync.read().status).toBe("offline")
// A new process recovered the file, which holds A
const poll = t.sync.poll()
t.next("GET").answer(state("S2", 1, "<mxfile>A</mxfile>"))
await poll
const push = t.next("POST")
expect(push.body).toMatchObject({
xml: "<mxfile>B</mxfile>",
stateId: "S2",
baseVersion: 1,
})
expect(t.recreated()).toBe(1)
})
it("fetches the new process's token after a 403 and then recovers", async () => {
const t = await inStep()
await edit(t, "<mxfile>B</mxfile>")
const refused = t.next("POST")
expect(refused.headers["X-Drawio-Token"]).toBe("test-token")
refused.answer({ status: 403 })
await t.settle()
// The page reads its own HTML again for the token
const reload = t.next("GET", location.href)
expect(reload.headers["X-Drawio-Token"]).toBeUndefined()
reload.answer({
status: 200,
body: '<script>window.__MCP_CONFIG__ = {"sessionId":"mcp-test","token":"0123abcd"};</script>',
})
await t.settle()
await t.settle()
// The push is retried with the new token; the new process refuses
// it for its state id, and the poll that follows recovers
const retried = t.next("POST")
expect(retried.headers["X-Drawio-Token"]).toBe("0123abcd")
retried.answer({
status: 409,
body: { error: "Session was recreated" },
})
await t.settle()
const poll = t.next("GET")
expect(poll.headers["X-Drawio-Token"]).toBe("0123abcd")
poll.answer(state("S2", 1, "<mxfile>A</mxfile>"))
await t.settle()
await t.settle()
expect(t.sync.read().stateId).toBe("S2")
const push = t.next("POST")
expect(push.headers["X-Drawio-Token"]).toBe("0123abcd")
expect(push.body).toMatchObject({
xml: "<mxfile>B</mxfile>",
stateId: "S2",
baseVersion: 1,
})
})
it("shows the server's diagram and keeps the tab's in History", async () => {
const t = await inStep()
const poll = t.sync.poll()
t.next("GET").answer(state("S2", 1, "<mxfile>C</mxfile>"))
await poll
expect(t.loads.at(-1)).toMatchObject({
xml: "<mxfile>C</mxfile>",
decision: { mode: "load" },
})
expect(t.next("POST").body).toMatchObject({
xml: "<mxfile>A</mxfile>",
source: "recover",
stateId: "S2",
})
})
it("sends an edit of the replaced canvas to History, until draw.io loaded", async () => {
const t = await inStep()
const poll = t.sync.poll()
t.next("GET").answer(state("S2", 1, "<mxfile>C</mxfile>"))
await poll
t.next("POST") // the tab's copy, to History
// An autosave the old canvas sent before the load
t.sync.onAutoSave("<mxfile>A edited</mxfile>")
await t.settle()
expect(t.next("POST").body).toMatchObject({
xml: "<mxfile>A edited</mxfile>",
source: "recover",
})
// After the load, edits are edits again
t.sync.onDrawioLoad()
await edit(t, "<mxfile>C edited</mxfile>")
expect(t.next("POST").body).toMatchObject({
xml: "<mxfile>C edited</mxfile>",
source: "edit",
})
})
it("ignores the late answer to an old state's push", async () => {
const t = await inStep()
await edit(t, "<mxfile>B</mxfile>")
const oldPush = t.next("POST")
const poll = t.sync.poll()
t.next("GET").answer(state("S2", 1, "<mxfile>B</mxfile>"))
await poll
oldPush.answer({ status: 200, body: { success: true, version: 21 } })
await t.settle()
expect(t.sync.read()).toMatchObject({
stateId: "S2",
currentVersion: 1,
})
})
it("drops a poll answer older than one already handled", async () => {
const t = await inStep()
const first = t.sync.poll()
const firstGet = t.next("GET")
const second = t.sync.poll()
t.next("GET").answer(state("S2", 1, "<mxfile>A</mxfile>"))
await second
// The answer from before the restart comes last
firstGet.answer(state("S1", 3, "<mxfile>old</mxfile>"))
await first
expect(t.sync.read().stateId).toBe("S2")
})
it("saves an undo made while a push was on its way", async () => {
const t = await inStep()
await edit(t, "<mxfile>B</mxfile>")
const pushB = t.next("POST")
// Undo back to A: equal to the last saved diagram, so no push
t.sync.onAutoSave("<mxfile>A</mxfile>")
await t.settle()
expect(t.posts()).toHaveLength(0)
pushB.answer({ status: 200, body: { success: true, version: 3 } })
await t.settle()
await t.settle()
expect(t.next("POST").body.xml).toBe("<mxfile>A</mxfile>")
})
it("ignores an edit's answer that comes after a newer AI write loaded", async () => {
const t = await inStep()
await edit(t, "<mxfile>B</mxfile>")
const pushB = t.next("POST")
// The AI wrote X after B; the poll's answer comes first
const poll = t.sync.poll()
t.next("GET").answer(state("S1", 4, "<mxfile>X</mxfile>"))
await poll
pushB.answer({ status: 200, body: { success: true, version: 3 } })
await t.settle()
await t.settle()
expect(t.sync.read()).toMatchObject({
currentVersion: 4,
lastXml: "<mxfile>X</mxfile>",
})
// No push of the AI's diagram as the user's edit
expect(t.posts()).toHaveLength(0)
})
it("keeps an undo when a poll sees the tab's own push first", async () => {
const t = await inStep()
await edit(t, "<mxfile>B</mxfile>")
const pushB = t.next("POST")
// Undo back to A while B is on its way (equal to the saved A: not sent)
t.sync.onAutoSave("<mxfile>A</mxfile>")
// The server already has B, and the poll's answer comes first
const loadsBefore = t.loads.length
const poll = t.sync.poll()
t.next("GET").answer(state("S1", 3, "<mxfile>B</mxfile>"))
await poll
expect(t.loads).toHaveLength(loadsBefore)
pushB.answer({ status: 200, body: { success: true, version: 3 } })
await t.settle()
await t.settle()
// The undo is saved
expect(t.next("POST").body.xml).toBe("<mxfile>A</mxfile>")
})
it("sends nothing more after a sync reply", async () => {
const t = await inStep()
const poll = t.sync.poll()
t.next("GET").answer(
state("S1", 2, "<mxfile>A</mxfile>", { syncRequested: true }),
)
await poll
const request = t.nextExport()
expect(request.request).toEqual({ format: "xml" })
// draw.io's export of the canvas, formatted unlike its autosave
request.answer({ xml: '<mxfile host="drawio">A</mxfile>' })
await t.settle()
const sync = t.next("POST")
expect(sync.body).toMatchObject({
source: "sync",
baseVersion: 2,
stateId: "S1",
})
sync.answer({ status: 200, body: { success: true, version: 3 } })
await t.settle()
await t.settle()
expect(t.posts()).toHaveLength(0)
expect(t.sync.read().currentVersion).toBe(3)
})
it("recovers from a stale push: the newer write loads, the edit is kept", async () => {
const t = await inStep()
await edit(t, "<mxfile>B</mxfile>")
// The AI wrote version 3 meanwhile: the push based on 2 is refused
t.next("POST").answer({
status: 409,
body: { error: "changed", version: 3, savedToHistory: true },
})
await t.settle()
expect(t.notices).toEqual(["aiChanged"])
// The 409 handler polls, and the poll loads the AI's write
t.next("GET").answer(state("S1", 3, "<mxfile>X</mxfile>"))
await t.settle()
await t.settle()
expect(t.sync.read()).toMatchObject({
currentVersion: 3,
lastXml: "<mxfile>X</mxfile>",
})
expect(t.loads.at(-1)?.xml).toBe("<mxfile>X</mxfile>")
})
})
describe("MCP sync status", () => {
it("waits for the first poll, then reports the connection", async () => {
const t = open()
expect(t.sync.read().status).toBe("waiting")
const poll = t.sync.poll()
t.next("GET").answer(state("S1", 1, "<mxfile>A</mxfile>"))
await poll
expect(t.statuses).toEqual(["connected"])
const failing = t.sync.poll()
t.next("GET").fail()
await failing
expect(t.statuses).toEqual(["connected", "offline"])
})
it("reports offline when the server refuses the poll", async () => {
const t = await inStep()
expect(t.statuses).toEqual(["connected"])
// Another process took the port: it refuses our token, and its page
// (an older version without the shell) has none to read
const poll = t.sync.poll()
t.next("GET").answer({ status: 403, body: { error: "Forbidden" } })
await t.settle()
t.next("GET", location.href).answer({ status: 404, body: "Not Found" })
await t.settle()
await t.settle()
t.next("GET").answer({ status: 403, body: { error: "Forbidden" } })
await poll
expect(t.statuses).toEqual(["connected", "offline"])
})
it("polls every 2 seconds once started", async () => {
const t = open()
t.sync.start()
expect(t.calls.filter((c) => c.method === "GET")).toHaveLength(1)
await vi.advanceTimersByTimeAsync(2000)
expect(t.calls.filter((c) => c.method === "GET")).toHaveLength(2)
t.sync.stop()
await vi.advanceTimersByTimeAsync(4000)
expect(t.calls.filter((c) => c.method === "GET")).toHaveLength(2)
})
it("reads the token the server wrote into the shell page", () => {
expect(
tokenFromPage(
'<script>window.__MCP_CONFIG__ = {"sessionId":"mcp-a","token":"abcd0123","apiBase":"/api"};</script>',
),
).toBe("abcd0123")
expect(tokenFromPage("<html></html>")).toBeNull()
expect(tokenFromPage("window.__MCP_CONFIG__ = {nope};")).toBeNull()
})
})
describe("MCP sync thumbnails", () => {
/** The tab loads the server write B at version 3 and asks for its image */
async function loadedB() {
const t = await inStep()
const poll = t.sync.poll()
t.next("GET").answer(state("S1", 3, "<mxfile>B</mxfile>"))
await poll
await vi.advanceTimersByTimeAsync(500)
const request = t.nextExport()
expect(request.request).toEqual({ format: "svg" })
return { t, request }
}
const thumbnailPosts = (t: ReturnType<typeof open>) =>
t.calls.filter((c) => c.url === "/api/history-svg")
it("sends the image with the state and version it shows", async () => {
const { t, request } = await loadedB()
request.answer({ data: "<svg/>" })
await t.settle()
expect(thumbnailPosts(t).map((c) => c.body)).toEqual([
expect.objectContaining({ stateId: "S1", version: 3 }),
])
expect(atob(thumbnailPosts(t)[0].body.svg.split(",")[1])).toBe("<svg/>")
})
it("drops the reply to an older thumbnail export", async () => {
const { t, request } = await loadedB()
// The next AI write loads before draw.io answered the first export
const poll = t.sync.poll()
t.next("GET").answer(state("S1", 4, "<mxfile>C</mxfile>"))
await poll
await vi.advanceTimersByTimeAsync(500)
const newer = t.nextExport()
request.answer({ data: "<svg>B</svg>" })
await t.settle()
expect(thumbnailPosts(t)).toHaveLength(0)
newer.answer({ data: "<svg>C</svg>" })
await t.settle()
expect(thumbnailPosts(t).map((c) => c.body.version)).toEqual([4])
})
it("drops the image when the user changed the canvas since the load", async () => {
const { t, request } = await loadedB()
t.sync.onAutoSave("<mxfile>B edited</mxfile>")
request.answer({ data: "<svg>thumbnail</svg>" })
await t.settle()
expect(thumbnailPosts(t)).toHaveLength(0)
// The edit is saved with the image of its own export
t.nextExport().answer({ data: "<svg>edit</svg>" })
await t.settle()
const push = t.next("POST")
expect(push.body.xml).toBe("<mxfile>B edited</mxfile>")
expect(atob(push.body.svg.split(",")[1])).toBe("<svg>edit</svg>")
})
it("takes the image of a write loaded before draw.io was ready, once it is", async () => {
const t = open()
const poll = t.sync.poll()
t.next("GET").answer(state("S1", 1, "<mxfile>A</mxfile>"))
await poll
await vi.advanceTimersByTimeAsync(500)
expect(t.exports).toHaveLength(0)
t.sync.setReady(true)
t.nextExport().answer({ data: "<svg/>" })
await t.settle()
expect(thumbnailPosts(t).map((c) => c.body.version)).toEqual([1])
})
})
describe("MCP sync with a diagram over the size limit", () => {
it("retries without the image, then tells the user", async () => {
const t = await inStep()
await edit(t, "<mxfile>huge</mxfile>")
const first = t.next("POST")
expect(first.body.svg).not.toBe("")
first.answer({ status: 413, body: { error: "Payload too large" } })
await t.settle()
const retry = t.next("POST")
expect(retry.body).toMatchObject({
xml: "<mxfile>huge</mxfile>",
svg: "",
})
expect(t.notices).toEqual([])
retry.answer({ status: 413, body: { error: "Payload too large" } })
await t.settle()
expect(t.posts()).toHaveLength(0)
expect(t.notices).toEqual(["tooLarge"])
})
it("pushes an edit without its image when draw.io does not export in time", async () => {
const t = await inStep()
t.sync.onAutoSave("<mxfile>B</mxfile>")
await vi.advanceTimersByTimeAsync(2000)
expect(t.next("POST").body).toMatchObject({
xml: "<mxfile>B</mxfile>",
svg: "",
baseVersion: 2,
})
})
})
describe("MCP sync export requests", () => {
const exportState = (extra: Record<string, unknown> = {}) =>
state("S1", 2, "<mxfile>A</mxfile>", {
exportFormat: "png",
exportId: "e1",
exportOptions: { width: 1000 },
...extra,
})
it("answers an export request once, with the server's id", async () => {
const t = await inStep()
const poll = t.sync.poll()
t.next("GET").answer(exportState())
await poll
const request = t.nextExport()
expect(request.request).toEqual({
format: "png",
scale: 2,
currentPage: true,
width: 1000,
})
// The server still shows the request on the next poll: not again
const again = t.sync.poll()
t.next("GET").answer(exportState())
await again
expect(t.exports).toHaveLength(0)
request.answer({ data: "data:image/png;base64,AAAA" })
await t.settle()
const result = t.next("POST")
expect(result.body).toEqual({
sessionId: "mcp-test",
exportData: "data:image/png;base64,AAAA",
exportId: "e1",
})
result.answer({ status: 200, body: { success: true } })
await t.settle()
await t.settle()
// The next request is a new export
const later = t.sync.poll()
t.next("GET").answer(exportState({ exportId: "e2" }))
await later
expect(t.exports).toHaveLength(1)
})
it("renders a page by its id without touching the canvas", async () => {
const t = await inStep()
const poll = t.sync.poll()
t.next("GET").answer(
exportState({ exportOptions: { width: 700, pageId: "p2" } }),
)
await poll
expect(t.nextExport().request).toEqual({
format: "png",
scale: 2,
currentPage: false,
width: 700,
pageId: "p2",
})
expect(t.transients).toEqual([])
})
it("shows a projection for a page export, ignoring edits meanwhile, then loads the document back", async () => {
const t = await inStep()
const poll = t.sync.poll()
t.next("GET").answer(
exportState({
exportFormat: "svg",
exportXml: "<mxfile>page 2 only</mxfile>",
}),
)
await poll
expect(t.transients).toEqual(["<mxfile>page 2 only</mxfile>"])
expect(t.sync.read().projectionActive).toBe(true)
// draw.io gets a moment to render before the export
expect(t.exports).toHaveLength(0)
await vi.advanceTimersByTimeAsync(600)
const request = t.nextExport()
expect(request.request).toEqual({ format: "svg" })
// An autosave of the projection is not the document
t.sync.onAutoSave("<mxfile>page 2 edited</mxfile>")
await t.settle()
expect(t.posts()).toHaveLength(0)
// A newer version does not fight the projection
const during = t.sync.poll()
t.next("GET").answer(
exportState({
version: 3,
xml: "<mxfile>A2</mxfile>",
exportFormat: "svg",
exportXml: "<mxfile>page 2 only</mxfile>",
}),
)
await during
expect(t.loads).toHaveLength(1)
request.answer({ data: "<svg>page 2</svg>" })
await t.settle()
const result = t.next("POST")
expect(result.body.exportData).toBe("<svg>page 2</svg>")
result.answer({ status: 200, body: { success: true } })
await t.settle()
await t.settle()
// The restore reloads the server's document in full
t.next("GET").answer(state("S1", 3, "<mxfile>A2</mxfile>"))
await t.settle()
await t.settle()
expect(t.loads.at(-1)).toEqual({
xml: "<mxfile>A2</mxfile>",
decision: { mode: "load" },
})
// Until draw.io reports that load, an autosave is still of the
// projection (the user moved a shape as the export ended)
expect(t.sync.read().projectionActive).toBe(true)
t.sync.onAutoSave("<mxfile>page 2 moved</mxfile>")
await t.settle()
expect(t.posts()).toHaveLength(0)
t.sync.onDrawioLoad()
expect(t.sync.read()).toMatchObject({
projectionActive: false,
currentVersion: 3,
})
// Edits are pushed again
await edit(t, "<mxfile>A2 edited</mxfile>")
expect(t.next("POST").body.xml).toBe("<mxfile>A2 edited</mxfile>")
})
/** A projection export whose result reached the server; the restore
* poll is pending */
async function projectionExported() {
const t = await inStep()
const poll = t.sync.poll()
t.next("GET").answer(
exportState({
exportFormat: "svg",
exportXml: "<mxfile>page 2 only</mxfile>",
}),
)
await poll
await vi.advanceTimersByTimeAsync(600)
t.nextExport().answer({ data: "<svg>page 2</svg>" })
await t.settle()
const result = t.next("POST")
return { t, result }
}
it("ends the projection 5 s after the restore load if draw.io never reports it", async () => {
const { t, result } = await projectionExported()
result.answer({ status: 200, body: { success: true } })
await t.settle()
await t.settle()
t.next("GET").answer(state("S1", 2, "<mxfile>A</mxfile>"))
await t.settle()
await t.settle()
expect(t.sync.read().projectionActive).toBe(true)
// No new export or sync starts over the projection meanwhile
const again = t.sync.poll()
t.next("GET").answer(
exportState({
exportId: "e2",
exportFormat: "svg",
exportXml: "<mxfile>page 2 only</mxfile>",
syncRequested: true,
}),
)
await again
expect(t.transients).toHaveLength(1)
expect(t.exports).toHaveLength(0)
await vi.advanceTimersByTimeAsync(5000)
expect(t.sync.read().projectionActive).toBe(false)
})
it("restores the document when the result POST never answers", async () => {
const { t, result } = await projectionExported()
// The result POST hangs; the server gives the export up at 15 s
await vi.advanceTimersByTimeAsync(15000)
expect(t.sync.read().projectionActive).toBe(true)
// The restore poll
t.next("GET").answer(state("S1", 3, "<mxfile>A3</mxfile>"))
await t.settle()
await t.settle()
expect(t.loads.at(-1)).toEqual({
xml: "<mxfile>A3</mxfile>",
decision: { mode: "load" },
})
t.sync.onDrawioLoad()
expect(t.sync.read().projectionActive).toBe(false)
// A new export request is answered
const later = t.sync.poll()
t.next("GET").answer(exportState({ version: 3, exportId: "e2" }))
await later
expect(t.exports).toHaveLength(1)
// The late answer changes nothing
result.answer({ status: 200, body: { success: true } })
await t.settle()
expect(t.exports).toHaveLength(1)
expect(t.sync.read().projectionActive).toBe(false)
})
it("does not restore the document twice when the result POST answers after the 15 s", async () => {
const { t, result } = await projectionExported()
await vi.advanceTimersByTimeAsync(15000)
t.next("GET").answer(state("S1", 2, "<mxfile>A</mxfile>"))
await t.settle()
await t.settle()
expect(t.loads).toHaveLength(2)
// The POST answers before draw.io reports the restore load: no
// second restore poll, no second load
result.answer({ status: 200, body: { success: true } })
await t.settle()
await t.settle()
expect(t.calls.filter((c) => c.method === "GET")).toHaveLength(0)
expect(t.loads).toHaveLength(2)
t.sync.onDrawioLoad()
expect(t.sync.read().projectionActive).toBe(false)
})
it("drops an export and reloads the document when draw.io starts over", async () => {
const t = await inStep()
const poll = t.sync.poll()
t.next("GET").answer(
exportState({
exportFormat: "svg",
exportXml: "<mxfile>page 2 only</mxfile>",
}),
)
await poll
await vi.advanceTimersByTimeAsync(600)
const request = t.nextExport()
// The frame remounts (an external draw.io on a theme switch): the
// projection is gone with it, the old frame's answer means nothing
t.sync.setReady(false)
request.answer({ data: "<svg>page 2</svg>" })
await t.settle()
expect(t.posts()).toHaveLength(0)
// The next poll puts the document back, in full
const restore = t.sync.poll()
t.next("GET").answer(
exportState({
exportFormat: "svg",
exportXml: "<mxfile>page 2 only</mxfile>",
}),
)
await restore
expect(t.loads.at(-1)).toEqual({
xml: "<mxfile>A</mxfile>",
decision: { mode: "load" },
})
expect(t.transients).toHaveLength(1)
// The new frame is ready: the server's export starts again
t.sync.setReady(true)
t.nextExport().answer({ data: "<svg>thumb</svg>" })
await t.settle()
t.next("POST", "/api/history-svg").answer({ status: 200, body: {} })
t.sync.onDrawioLoad()
const again = t.sync.poll()
t.next("GET").answer(
exportState({
exportFormat: "svg",
exportXml: "<mxfile>page 2 only</mxfile>",
}),
)
await again
expect(t.transients).toHaveLength(2)
await vi.advanceTimersByTimeAsync(600)
expect(t.exports.map((e) => e.request)).toEqual([{ format: "svg" }])
})
it("gives up on an export draw.io never answers and restores the document", async () => {
const t = await inStep()
const poll = t.sync.poll()
t.next("GET").answer(
exportState({
exportFormat: "svg",
exportXml: "<mxfile>page 2 only</mxfile>",
}),
)
await poll
await vi.advanceTimersByTimeAsync(600)
expect(t.exports).toHaveLength(1)
await vi.advanceTimersByTimeAsync(10000)
expect(t.posts()).toHaveLength(0)
// The restore poll
t.next("GET").answer(state("S1", 2, "<mxfile>A</mxfile>"))
await t.settle()
await t.settle()
expect(t.loads.at(-1)).toEqual({
xml: "<mxfile>A</mxfile>",
decision: { mode: "load" },
})
})
it("waits for the icons of a write that came with the export", async () => {
const t = await inStep()
const poll = t.sync.poll()
t.next("GET").answer(
exportState({ version: 3, xml: "<mxfile>B</mxfile>" }),
)
await poll
expect(t.loads.at(-1)?.xml).toBe("<mxfile>B</mxfile>")
expect(t.exports).toHaveLength(0)
// The write's thumbnail comes first (500 ms), the screenshot at 600
await vi.advanceTimersByTimeAsync(599)
expect(t.exports.map((e) => e.request.format)).toEqual(["svg"])
await vi.advanceTimersByTimeAsync(1)
expect(t.exports.map((e) => e.request.format)).toEqual(["svg", "png"])
})
})
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,
body: {
entries: [{ id: 5, index: 0, svg: "" }],
count: 1,
stateId: "S1",
currentId: null,
},
}
it("drops a History list asked for before the session was recreated", async () => {
const t = await inStep()
const history = t.sync.fetchHistory()
const list = t.next("GET", "/api/history")
// The server restarts before the list arrives: its ids are old
const poll = t.sync.poll()
t.next("GET", "/api/state").answer(state("S2", 1, "<mxfile>A</mxfile>"))
await poll
list.answer(historyList)
expect(await history).toBeNull()
})
it("names the state its History list belongs to when restoring", async () => {
const t = await inStep()
const history = t.sync.fetchHistory()
t.next("GET", "/api/history").answer(historyList)
const list = await history
expect(list).toEqual({
entries: [{ id: 5, index: 0, svg: "" }],
stateId: "S1",
currentId: null,
})
const restore = t.sync.restoreEntry(5, list?.stateId ?? null)
await t.settle()
const request = t.next("POST", "/api/restore")
expect(request.body).toEqual({
sessionId: "mcp-test",
id: 5,
stateId: "S1",
})
request.answer({ status: 200, body: { success: true, newVersion: 3 } })
await t.settle()
// The restore polls for the restored diagram
t.next("GET", "/api/state").answer(
state("S1", 3, "<mxfile>old</mxfile>"),
)
expect(await restore).toBe("ok")
expect(t.loads.at(-1)?.xml).toBe("<mxfile>old</mxfile>")
})
it("restores from an old list with that list's state, which the server refuses", async () => {
const t = await inStep()
const history = t.sync.fetchHistory()
t.next("GET", "/api/history").answer(historyList)
const list = await history
// The server restarts; the list on screen is from S1
const poll = t.sync.poll()
t.next("GET", "/api/state").answer(state("S2", 1, "<mxfile>A</mxfile>"))
await poll
expect(t.recreated()).toBe(1)
const restore = t.sync.restoreEntry(5, list?.stateId ?? null)
await t.settle()
const request = t.next("POST", "/api/restore")
expect(request.body.stateId).toBe("S1")
request.answer({
status: 409,
body: { error: "Session was recreated" },
})
expect(await restore).toBe("stale")
expect(t.notices).toEqual(["historyChanged"])
})
it("reports a restore that failed", async () => {
const t = await inStep()
const restore = t.sync.restoreEntry(5, "S1")
await t.settle()
t.next("POST", "/api/restore").fail()
expect(await restore).toBe("failed")
expect(t.notices).toEqual(["restoreFailed"])
})
it("passes on the entry the server says is on the canvas", async () => {
const t = await inStep()
const history = t.sync.fetchHistory()
t.next("GET", "/api/history").answer({
status: 200,
body: { entries: [{ id: 7 }], count: 1, currentId: 7 },
})
expect((await history)?.currentId).toBe(7)
})
})
// The version cards read History again when the server changed
describe("MCP sync server change listener", () => {
it("tells once per server version: the first poll, a write, the tab's own edit", async () => {
const t = open()
let changes = 0
const stop = t.sync.onServerChange(() => changes++)
t.sync.setReady(true)
let poll = t.sync.poll()
t.next("GET").answer(state("S1", 2, "<mxfile>A</mxfile>"))
await poll
expect(changes).toBe(1)
// The same version again: nothing new
poll = t.sync.poll()
t.next("GET").answer(state("S1", 2, "<mxfile>A</mxfile>"))
await poll
expect(changes).toBe(1)
// The thumbnail of the write is stored: its entry has a picture now
await vi.advanceTimersByTimeAsync(500)
t.nextExport().answer({ data: "<svg>A</svg>" })
await t.settle()
t.next("POST", "/api/history-svg").answer({ status: 200, body: {} })
await t.settle()
expect(changes).toBe(2)
// An AI write
poll = t.sync.poll()
t.next("GET").answer(state("S1", 3, "<mxfile>B</mxfile>"))
await poll
expect(changes).toBe(3)
// The user's edit is saved: the canvas is at no version now
await edit(t, "<mxfile>C</mxfile>")
t.next("POST", "/api/state").answer({
status: 200,
body: { success: true, version: 4 },
})
await t.settle()
expect(changes).toBe(4)
// The next poll sees the version the push already told about
poll = t.sync.poll()
t.next("GET").answer(state("S1", 4, "<mxfile>C</mxfile>"))
await poll
expect(changes).toBe(4)
stop()
poll = t.sync.poll()
t.next("GET").answer(state("S1", 5, "<mxfile>D</mxfile>"))
await poll
expect(changes).toBe(4)
})
it("tells when a rejected edit was kept in History, and when the state was recreated", async () => {
const t = await inStep()
let changes = 0
t.sync.onServerChange(() => changes++)
await edit(t, "<mxfile>B</mxfile>")
t.next("POST", "/api/state").answer({
status: 409,
body: { error: "changed", version: 3, savedToHistory: true },
})
await t.settle()
expect(changes).toBe(1)
// The 409 handler polls and loads the newer write
t.next("GET").answer(state("S1", 3, "<mxfile>AI</mxfile>"))
await t.settle()
expect(changes).toBe(2)
// The server restarted: History has new ids
const poll = t.sync.poll()
t.next("GET").answer(state("S2", 1, "<mxfile>AI</mxfile>"))
await poll
expect(changes).toBe(3)
})
})
// Documents as the server writes them (one page per call)
const cell = (id: string, label: string, x = 40) =>
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="${x}" y="40" width="120" height="60" as="geometry"/></mxCell>`
const page = (id: string, name: string, cells: string, attrs = "") =>
`<diagram id="${id}" name="${name}"><mxGraphModel${attrs}><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
const file = (pages: string, attrs = "") =>
`<mxfile host="app.diagrams.net"${attrs}>${pages}</mxfile>`
// The same page as draw.io saves it: other attribute order, view settings
const drawioPage = (id: string, name: string, cells: string) =>
`<diagram name="${name}" id="${id}"><mxGraphModel dx="1000" dy="600" grid="1" gridSize="10" page="1"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
describe("decideLoad: one undo step on the page on screen, or a full load", () => {
const onCanvas = file(
page("p1", "One", cell("a", "A")) + page("p2", "Two", cell("b", "B")),
)
it.each([
[
"the page on screen changed, the other did not",
file(
page("p1", "One", cell("a", "A2")) +
page("p2", "Two", cell("b", "B")),
),
"p1",
{ mode: "commit", pageId: "p1" },
],
[
"the change is on the second page, which is on screen",
file(
page("p1", "One", cell("a", "A")) +
page("p2", "Two", cell("b", "B2")),
),
"p2",
{ mode: "commit", pageId: "p2" },
],
[
"another page than the one on screen changed",
file(
page("p1", "One", cell("a", "A")) +
page("p2", "Two", cell("b", "B2")),
),
"p1",
{ mode: "load" },
],
[
"a page was added",
file(
page("p1", "One", cell("a", "A")) +
page("p2", "Two", cell("b", "B")) +
page("p3", "Three", ""),
),
"p1",
{ mode: "load" },
],
[
"a page was removed",
file(page("p1", "One", cell("a", "A"))),
"p1",
{ mode: "load" },
],
[
"the other page was renamed",
file(
page("p1", "One", cell("a", "A2")) +
page("p2", "Deux", cell("b", "B")),
),
"p1",
{ mode: "load" },
],
[
"the file variables changed",
file(
page("p1", "One", cell("a", "A2")) +
page("p2", "Two", cell("b", "B")),
' vars="{&quot;x&quot;:&quot;1&quot;}"',
),
"p1",
{ mode: "load" },
],
[
"the page needs a setting only a full load applies",
file(
page("p1", "One", cell("a", "A2"), ' shadow="1"') +
page("p2", "Two", cell("b", "B")),
),
"p1",
{ mode: "load" },
],
[
"the page on screen has another id in the written document",
file(
page("p9", "One", cell("a", "A2")) +
page("p2", "Two", cell("b", "B")),
),
"p1",
{ mode: "load" },
],
[
"the page on screen is unknown with several pages: a full load",
file(
page("p1", "One", cell("a", "A2")) +
page("p2", "Two", cell("b", "B")),
),
null,
{ mode: "load" },
],
] as [string, string, string | null, LoadDecision][])(
"%s",
(_name, written, pageId, expected) => {
expect(decideLoad(onCanvas, written, pageId)).toEqual(expected)
},
)
it("loads in full when the canvas is empty or either side is no document", () => {
const written = file(page("p1", "One", cell("a", "A")))
expect(decideLoad("", written, null)).toEqual({ mode: "load" })
expect(decideLoad(written, "<mxfile>A</mxfile>", "p1")).toEqual({
mode: "load",
})
expect(decideLoad("<not xml", written, "p1")).toEqual({ mode: "load" })
})
it("commits a one-page write over a one-page canvas whatever the page ids", () => {
const canvas = file(drawioPage("abc", "Page-1", cell("a", "A")))
const written = file(
page("xyz", "Page-1", cell("a", "A") + cell("c", "C", 300)),
)
expect(decideLoad(canvas, written, "abc")).toEqual({
mode: "commit",
pageId: "xyz",
})
})
it("reads the other pages as draw.io saved them", () => {
// draw.io's own serialization of page two is the same page
const canvas = file(
drawioPage("p1", "One", cell("a", "A")) +
drawioPage("p2", "Two", cell("b", "B")),
)
const written = file(
page("p1", "One", cell("a", "A2")) +
page("p2", "Two", cell("b", "B")),
)
expect(decideLoad(canvas, written, "p1")).toEqual({
mode: "commit",
pageId: "p1",
})
})
})
describe("MCP sync with server writes taken in place", () => {
const canvasXml = file(
drawioPage("p1", "One", cell("a", "A")) +
drawioPage("p2", "Two", cell("b", "B")),
)
const written = file(
page("p1", "One", cell("a", "A") + cell("c", "C", 300)) +
page("p2", "Two", cell("b", "B")),
)
it("commits a write that changes the page on screen", async () => {
const t = await inStep({ currentPageId: "p1" })
t.canvas.xml = canvasXml
const poll = t.sync.poll()
t.next("GET").answer(state("S1", 3, written))
await poll
expect(t.loads.at(-1)).toEqual({
xml: written,
decision: { mode: "commit", pageId: "p1" },
})
})
it("does not push draw.io's own copy of the write, and keeps its thumbnail", async () => {
const t = await inStep({ currentPageId: "p1" })
t.canvas.xml = canvasXml
const poll = t.sync.poll()
t.next("GET").answer(state("S1", 3, written))
await poll
// Replacing the page in place makes draw.io autosave the result,
// serialized its way
const drawioCopy = file(
drawioPage("p1", "One", cell("a", "A") + cell("c", "C", 300)) +
drawioPage("p2", "Two", cell("b", "B")),
)
t.sync.onAutoSave(drawioCopy)
await t.settle()
expect(t.posts()).toHaveLength(0)
expect(t.exports).toHaveLength(0)
// The thumbnail still pictures the write
await vi.advanceTimersByTimeAsync(500)
t.nextExport().answer({ data: "<svg/>" })
await t.settle()
expect(t.next("POST", "/api/history-svg").body).toMatchObject({
version: 3,
})
// A real edit is pushed
await edit(
t,
file(
drawioPage("p1", "One", cell("c", "C", 300)) +
drawioPage("p2", "Two", cell("b", "B")),
),
)
expect(t.next("POST").body.source).toBe("edit")
})
it("after a restart, loads the server's diagram in full and keeps the tab's copy", async () => {
const t = await inStep({ currentPageId: "p1" })
t.canvas.xml = canvasXml
t.sync.onAutoSave(canvasXml)
t.nextExport().answer({ data: "<svg/>" })
await t.settle()
t.next("POST").answer({
status: 200,
body: { success: true, version: 3 },
})
await t.settle()
const poll = t.sync.poll()
t.next("GET").answer(state("S2", 1, written))
await poll
// Even a write the page on screen could take in place: whether it
// does is decided in DiagramProvider, and an autosave before
// draw.io reports the load is of the canvas being replaced
expect(t.loads.at(-1)?.decision).toEqual({ mode: "load" })
expect(t.next("POST").body).toMatchObject({
xml: canvasXml,
source: "recover",
stateId: "S2",
})
t.sync.onAutoSave(
file(
drawioPage("p1", "One", cell("a", "A2")) +
drawioPage("p2", "Two", cell("b", "B")),
),
)
await t.settle()
expect(t.next("POST").body.source).toBe("recover")
t.sync.onDrawioLoad()
await edit(
t,
file(
drawioPage("p1", "One", cell("c", "C", 300)) +
drawioPage("p2", "Two", cell("b", "B")),
),
)
expect(t.next("POST").body.source).toBe("edit")
})
it("after a restart, an autosave of the old one-page canvas goes to History (external draw.io)", async () => {
// An external draw.io: the page on screen is unknown, so decideLoad
// would call a one-page write a commit; the provider loads in full
// anyway (no editor access), and draw.io applies the load later
const t = open({ currentPageId: null })
const canvasB = file(drawioPage("abc", "Page-1", cell("a", "B")))
const canvasC = file(page("xyz", "Page-1", cell("a", "C")))
t.sync.setReady(true)
let poll = t.sync.poll()
t.next("GET").answer(state("S1", 2, canvasB))
await poll
expect(decideLoad(canvasB, canvasC, null).mode).toBe("commit")
// The server restarts and recovers C, a one-page document like B
poll = t.sync.poll()
t.next("GET").answer(state("S2", 1, canvasC))
await poll
expect(t.loads.at(-1)?.decision).toEqual({ mode: "load" })
expect(t.next("POST").body).toMatchObject({
xml: canvasB,
source: "recover",
})
// An edit of B that draw.io queued before the load of C
const editedB = file(drawioPage("abc", "Page-1", cell("a", "B2")))
t.sync.onAutoSave(editedB)
await t.settle()
expect(t.next("POST").body).toMatchObject({
xml: editedB,
source: "recover",
baseVersion: 1,
stateId: "S2",
})
expect(t.sync.read().lastXml).toBe(canvasC)
t.sync.onDrawioLoad()
await edit(t, file(drawioPage("xyz", "Page-1", cell("a", "C2"))))
expect(t.next("POST").body.source).toBe("edit")
})
it("a change of the paper size alone is pushed", async () => {
const t = await inStep({ currentPageId: "p1" })
t.canvas.xml = canvasXml
const poll = t.sync.poll()
t.next("GET").answer(state("S1", 3, written))
await poll
expect(t.loads.at(-1)?.decision.mode).toBe("commit")
// draw.io's copy of the write names the paper size the write left
// out: the same document, not an edit
const sized = (width: number, height: number) =>
file(
page(
"p1",
"One",
cell("a", "A") + cell("c", "C", 300),
` dx="1000" dy="600" grid="1" pageWidth="${width}" pageHeight="${height}"`,
) + drawioPage("p2", "Two", cell("b", "B")),
)
t.sync.onAutoSave(sized(850, 1100))
await t.settle()
expect(t.posts()).toHaveLength(0)
expect(t.exports).toHaveLength(0)
// The user picks A4: the same document to isSameDocument, an edit
// to the user
t.sync.onAutoSave(sized(827, 1169))
expect(t.exports.map((e) => e.request.format)).toEqual(["svg"])
t.nextExport().answer({ data: "<svg/>" })
await t.settle()
expect(t.next("POST").body).toMatchObject({
xml: sized(827, 1169),
source: "edit",
baseVersion: 3,
})
})
it("a change of the paper size after a write that changed nothing is pushed", async () => {
const t = await inStep({ currentPageId: "p1" })
t.canvas.xml = canvasXml
let poll = t.sync.poll()
t.next("GET").answer(state("S1", 3, written))
await poll
const drawioCopy = file(
drawioPage("p1", "One", cell("a", "A") + cell("c", "C", 300)) +
drawioPage("p2", "Two", cell("b", "B")),
)
t.sync.onAutoSave(drawioCopy)
t.canvas.xml = drawioCopy
await t.settle()
expect(t.posts()).toHaveLength(0)
// The same document written again (the file loaded once more): the
// server has a new version, draw.io sees no change and sends no copy
poll = t.sync.poll()
t.next("GET").answer(state("S1", 4, written))
await poll
expect(t.loads.at(-1)?.decision.mode).toBe("commit")
// The user picks A4: an edit, not the copy of the write
const a4 = file(
page(
"p1",
"One",
cell("a", "A") + cell("c", "C", 300),
' dx="1000" dy="600" grid="1" pageWidth="827" pageHeight="1169"',
) + drawioPage("p2", "Two", cell("b", "B")),
)
t.sync.onAutoSave(a4)
expect(t.exports.map((e) => e.request.format)).toEqual(["svg"])
t.nextExport().answer({ data: "<svg/>" })
await t.settle()
expect(t.next("POST").body).toMatchObject({
xml: a4,
source: "edit",
baseVersion: 4,
})
})
})