mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
fix(mcp-server): review fixes for the shell's sync core
A recovery loads the server's diagram in full and waits for draw.io's load report, as the classic page does: whether the canvas takes a write in place is decided inside DiagramProvider, and an autosave of the canvas being replaced went to the server as an edit when the decision was wrong. A paper size change alone is pushed again: autosaves are compared as documents only for draw.io's own copy of a committed write. A projection stays on screen until draw.io reports the restore load (5 s at most), the whole export ends in 15 s even when its result POST hangs, and an export in flight is dropped when draw.io starts over. A refused poll shows the tab offline. With several pages and the page on screen unknown, a write loads in full. hasLoadOnlySettings and the highlight of an AI change are shared with the editor bridge.
This commit is contained in:
@@ -4,7 +4,10 @@ import { toast } from "sonner"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
||||
import { clearHighlights, highlightCells } from "@/lib/drawio/editor-bridge"
|
||||
import {
|
||||
clearHighlights,
|
||||
highlightChangedCells,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
@@ -63,14 +66,7 @@ export function useVersions({
|
||||
fromScratch,
|
||||
})
|
||||
// Mark what changed, unless the whole diagram is new
|
||||
if (!fromScratch) {
|
||||
setTimeout(() => {
|
||||
const marker = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--marker")
|
||||
.trim()
|
||||
highlightCells(touchedIds, marker || "#ffd84d")
|
||||
}, 60)
|
||||
}
|
||||
if (!fromScratch) highlightChangedCells(touchedIds)
|
||||
// The picture is of the canvas: take it right away, before a
|
||||
// newer version can be on it; once one is, this one gets none
|
||||
// rather than the newer one's
|
||||
|
||||
+28
-13
@@ -262,19 +262,19 @@ function hasShapes(xml: string): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
// Page settings draw.io applies on a full load only: replacing the page in
|
||||
// place (Editor.readGraphState) keeps the old ones. (Its adaptive colors and
|
||||
// theme stay too: a diagram the AI writes does not set them.)
|
||||
function hasLoadOnlySettings(model: string): boolean {
|
||||
const page = new DOMParser().parseFromString(
|
||||
model,
|
||||
"text/xml",
|
||||
).documentElement
|
||||
/**
|
||||
* Page settings draw.io applies on a full load only: replacing the page in
|
||||
* place (Editor.readGraphState) keeps the old ones. (Its adaptive colors and
|
||||
* theme stay too: a diagram the AI writes does not set them.) Takes the
|
||||
* page's mxGraphModel element; the MCP shell's sync asks the same question
|
||||
* of a document before it reaches the editor.
|
||||
*/
|
||||
export function hasLoadOnlySettings(model: Element): boolean {
|
||||
return (
|
||||
page.hasAttribute("backgroundImage") ||
|
||||
page.hasAttribute("extFonts") ||
|
||||
page.getAttribute("math") === "1" ||
|
||||
page.getAttribute("shadow") === "1"
|
||||
model.hasAttribute("backgroundImage") ||
|
||||
model.hasAttribute("extFonts") ||
|
||||
model.getAttribute("math") === "1" ||
|
||||
model.getAttribute("shadow") === "1"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -322,7 +322,9 @@ export function canReplaceDiagram(xml: string): boolean {
|
||||
// A document with them, or replacing one with them, loads in full
|
||||
return (
|
||||
model !== null &&
|
||||
!hasLoadOnlySettings(model) &&
|
||||
!hasLoadOnlySettings(
|
||||
new DOMParser().parseFromString(model, "text/xml").documentElement,
|
||||
) &&
|
||||
!canvasHasLoadOnlySettings()
|
||||
)
|
||||
}
|
||||
@@ -547,6 +549,19 @@ export function highlightCells(ids: string[], color: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the cells an AI change touched in the page's marker color, once
|
||||
* draw.io has drawn them (the web app's versions and the MCP shell share it)
|
||||
*/
|
||||
export function highlightChangedCells(ids: string[]) {
|
||||
setTimeout(() => {
|
||||
const marker = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--marker")
|
||||
.trim()
|
||||
highlightCells(ids, marker || "#ffd84d")
|
||||
}, 60)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* it) and screenshots and file exports for an image, both through the poll.
|
||||
*/
|
||||
import { isSameDocument, sameFileVars } from "@/lib/diagram-diff"
|
||||
import { hasLoadOnlySettings } from "@/lib/drawio/editor-bridge"
|
||||
import { contentFingerprint } from "@/packages/mcp-server/src/edit-gate.ts"
|
||||
import {
|
||||
normalizeToMxfile,
|
||||
@@ -120,7 +121,9 @@ export interface McpSync {
|
||||
/** draw.io reported a load done */
|
||||
onDrawioLoad(): void
|
||||
/** The server's History, with the state its ids belong to; null when
|
||||
* it could not be read, or the state changed meanwhile */
|
||||
* it could not be read, or the state changed meanwhile. For the versions
|
||||
* strip (plan step 5), with restoreEntry below; until then only the
|
||||
* unit tests call them */
|
||||
fetchHistory(): Promise<{
|
||||
entries: HistoryEntryInfo[]
|
||||
stateId: string | null
|
||||
@@ -174,24 +177,12 @@ function svgDataUrl(svg: string): string {
|
||||
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`
|
||||
}
|
||||
|
||||
// Page settings draw.io applies on a full load only (the editor bridge's
|
||||
// hasLoadOnlySettings): replacing the page in place would keep the old ones
|
||||
function hasLoadOnlySettings(page: Element): boolean {
|
||||
const model = page.querySelector("mxGraphModel")
|
||||
return (
|
||||
!!model &&
|
||||
(model.hasAttribute("backgroundImage") ||
|
||||
model.hasAttribute("extFonts") ||
|
||||
model.getAttribute("math") === "1" ||
|
||||
model.getAttribute("shadow") === "1")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a server write can go on the canvas as one undo step: it has the
|
||||
* same pages as the canvas, changes only the one the user is viewing (the
|
||||
* one with currentPageId, else the first), keeps the file variables and
|
||||
* needs no load-only page setting. Pages are compared with the MCP core's
|
||||
* one with currentPageId; a one-page document needs no id, several pages
|
||||
* with the page unknown load in full), keeps the file variables and needs
|
||||
* no load-only page setting. Pages are compared with the MCP core's
|
||||
* contentFingerprint (names and cells). The editor bridge checks the same
|
||||
* against the live editor and falls back to a full load on its own.
|
||||
*/
|
||||
@@ -221,10 +212,15 @@ export function decideLoad(
|
||||
if (pagesNow.length === 0 || pagesNow.length !== pagesNext.length) {
|
||||
return full
|
||||
}
|
||||
const index = Math.max(
|
||||
0,
|
||||
pagesNow.findIndex((page) => page.getAttribute("id") === currentPageId),
|
||||
let index = pagesNow.findIndex(
|
||||
(page) => page.getAttribute("id") === currentPageId,
|
||||
)
|
||||
if (index < 0) {
|
||||
// The page on screen is unknown (an external draw.io cannot tell):
|
||||
// with several pages the target cannot be told, load in full
|
||||
if (pagesNow.length > 1) return full
|
||||
index = 0
|
||||
}
|
||||
const target = pagesNext[index]
|
||||
// The page is found by its id when the document has several
|
||||
if (
|
||||
@@ -233,7 +229,8 @@ export function decideLoad(
|
||||
) {
|
||||
return full
|
||||
}
|
||||
if (hasLoadOnlySettings(target)) return full
|
||||
const targetModel = target.querySelector("mxGraphModel")
|
||||
if (targetModel && hasLoadOnlySettings(targetModel)) return full
|
||||
// The other pages, as the MCP core compares them (edit-gate.ts)
|
||||
const others = (doc: Document, pages: Element[]) => {
|
||||
pages[index].remove()
|
||||
@@ -262,6 +259,9 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
// an autosave still on its way belongs to the canvas being replaced
|
||||
let awaitingLoad = false
|
||||
let awaitingLoadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// A write taken in place (a commit) makes draw.io autosave its own copy
|
||||
// of it, serialized its way: that one autosave is not an edit
|
||||
let awaitingCommitCopy = false
|
||||
let pollSeq = 0
|
||||
let lastHandledPoll = 0 // polls overlap; older answers are dropped
|
||||
// The edit whose SVG export is pending, and what it was based on
|
||||
@@ -278,6 +278,14 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
let mcpExportId: string | null = null
|
||||
// A one-page projection is on screen for a page-targeted export
|
||||
let projectionExportActive = false
|
||||
// The restore load of the real document was sent; until draw.io reports
|
||||
// it (or 5 s), the projection still counts as on screen
|
||||
let projectionRestoreTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const endProjection = () => {
|
||||
projectionExportActive = false
|
||||
if (projectionRestoreTimer) clearTimeout(projectionRestoreTimer)
|
||||
projectionRestoreTimer = null
|
||||
}
|
||||
// Load the server state on the next poll even at the same version
|
||||
let forceReload = false
|
||||
let pendingSyncExport = false
|
||||
@@ -345,10 +353,10 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
? decideLoad(canvas.currentXml(), xml, canvas.currentPageId())
|
||||
: { mode: "load" }
|
||||
canvas.load(xml, decision)
|
||||
awaitingCommitCopy = decision.mode === "commit"
|
||||
// currentVersion is the write's version
|
||||
thumbExport = { stateId, version: currentVersion, xml }
|
||||
setTimeout(captureThumbnail, 500)
|
||||
return decision
|
||||
}
|
||||
|
||||
// The image of the server write on the canvas, for its History entry:
|
||||
@@ -469,7 +477,7 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
options.onStateRecreated?.()
|
||||
// The old state's pending work is gone with it
|
||||
const projectionShown = projectionExportActive
|
||||
projectionExportActive = false
|
||||
endProjection()
|
||||
forceReload = false
|
||||
pendingMcpExport = null
|
||||
pendingSyncExport = false
|
||||
@@ -489,10 +497,13 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
} else if (s.xml) {
|
||||
// The server has a diagram this tab never showed (an AI write it
|
||||
// missed, a saved file): show that, and keep this tab's copy in
|
||||
// History unless it is the same
|
||||
const decision = loadFromServer(s.xml, "decide")
|
||||
// A commit replaces the page in place: draw.io reports no load
|
||||
if (decision.mode === "load") expectLoad()
|
||||
// History unless it is the same. A full load, as the classic
|
||||
// page does: whether the canvas can take the write in place is
|
||||
// decided inside DiagramProvider, and the sync cannot tell; until
|
||||
// draw.io reports the load, an autosave is from the canvas being
|
||||
// replaced
|
||||
loadFromServer(s.xml, "full")
|
||||
expectLoad()
|
||||
if (mine && !sameDiagram(mine, s.xml)) {
|
||||
pushState(mine, "", s.version, "recover")
|
||||
}
|
||||
@@ -581,6 +592,16 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
} else {
|
||||
fire()
|
||||
}
|
||||
// The whole export, result delivery included, ends in time: a result
|
||||
// POST that never answers must not keep the projection on screen
|
||||
// past the server's deadline (15 s). Only for this export: a later
|
||||
// one may be running by then.
|
||||
setTimeout(() => {
|
||||
if (pendingMcpExport && seq === mcpExportSeq) {
|
||||
pendingMcpExport = null
|
||||
restoreFromProjection()
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
@@ -590,7 +611,12 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
const r = await api(
|
||||
`/state?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
)
|
||||
if (!r.ok) return
|
||||
if (!r.ok) {
|
||||
// Refused (another process answers on this port and its
|
||||
// token could not be read) or failing: edits are not saved
|
||||
setStatus("offline")
|
||||
return
|
||||
}
|
||||
const s: ServerState = await r.json()
|
||||
// An older answer than one already handled (the interval, the
|
||||
// 409 handler and the projection restore each poll): it could
|
||||
@@ -619,9 +645,14 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
// document the user had
|
||||
const how = forceReload ? "full" : "decide"
|
||||
forceReload = false
|
||||
projectionExportActive = false
|
||||
currentVersion = s.version
|
||||
loadFromServer(s.xml, how)
|
||||
// The projection stays on screen until draw.io reports this
|
||||
// load (its messages come in order): an autosave before that
|
||||
// is of the projection, not an edit
|
||||
if (projectionExportActive && !projectionRestoreTimer) {
|
||||
projectionRestoreTimer = setTimeout(endProjection, 5000)
|
||||
}
|
||||
justLoaded = true
|
||||
}
|
||||
// A sync request (get_diagram): after the load above, so draw.io
|
||||
@@ -635,7 +666,14 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
) {
|
||||
startSyncExport()
|
||||
}
|
||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
||||
// Nor an export while the projection is still on screen: it
|
||||
// would picture the projection
|
||||
if (
|
||||
s.exportFormat &&
|
||||
!pendingMcpExport &&
|
||||
isReady &&
|
||||
!projectionExportActive
|
||||
) {
|
||||
startMcpExport(s, justLoaded)
|
||||
}
|
||||
// Extension point (plan step 6, the get_selection tool): a
|
||||
@@ -659,6 +697,16 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
poll,
|
||||
setReady(ready) {
|
||||
isReady = ready
|
||||
// draw.io is starting over (an external editor reloads on a
|
||||
// theme switch): an export in flight gets no answer, and a
|
||||
// projection on the canvas is gone with the old frame. The next
|
||||
// poll puts the document back and starts the server's export
|
||||
// again
|
||||
if (!ready && pendingMcpExport !== null) {
|
||||
mcpExportSeq++
|
||||
pendingMcpExport = null
|
||||
if (projectionExportActive) forceReload = true
|
||||
}
|
||||
// A write loaded before draw.io was ready gets its thumbnail now
|
||||
if (ready && thumbExport) captureThumbnail()
|
||||
},
|
||||
@@ -674,9 +722,16 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
return
|
||||
}
|
||||
// Also an edit undone back to what the server has, or draw.io's
|
||||
// own copy of a write it took in place (a commit)
|
||||
// own copy of a write it took in place (a commit). Compared as
|
||||
// documents only for that copy: a change of the paper size alone
|
||||
// is the same document to isSameDocument when the server's copy
|
||||
// names none
|
||||
latestXml = xml
|
||||
if (sameDiagram(xml, lastXml)) return
|
||||
const commitCopy = awaitingCommitCopy
|
||||
awaitingCommitCopy = false
|
||||
if (xml === lastXml || (commitCopy && sameDiagram(xml, lastXml))) {
|
||||
return
|
||||
}
|
||||
// Request an SVG export, then push the edit with it; remember
|
||||
// the version and state it is based on. Without an answer in
|
||||
// time, push it without the image.
|
||||
@@ -693,6 +748,10 @@ export function createMcpSync(options: SyncOptions): McpSync {
|
||||
},
|
||||
onDrawioLoad() {
|
||||
awaitingLoad = false
|
||||
// A commit that fell back to a full load sends no copy
|
||||
awaitingCommitCopy = false
|
||||
// The restore load after a projection export is done
|
||||
if (projectionRestoreTimer) endProjection()
|
||||
},
|
||||
async fetchHistory() {
|
||||
// A list for a state the server recreated meanwhile has old ids
|
||||
|
||||
@@ -3,7 +3,7 @@ import { toast } from "sonner"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { diffDiagrams } from "@/lib/diagram-diff"
|
||||
import { highlightCells } from "@/lib/drawio/editor-bridge"
|
||||
import { highlightChangedCells } from "@/lib/drawio/editor-bridge"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { createMcpSync, type McpSync, type SyncStatus } from "./mcp-sync-core"
|
||||
import type { ShellConfig } from "./runtime-config"
|
||||
@@ -102,14 +102,7 @@ export function useMcpSync(config: ShellConfig): SyncStatus {
|
||||
afterXml,
|
||||
pageId,
|
||||
)
|
||||
if (fromScratch) return
|
||||
// Once draw.io has drawn the replaced cells
|
||||
setTimeout(() => {
|
||||
const marker = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--marker")
|
||||
.trim()
|
||||
highlightCells(touchedIds, marker || "#ffd84d")
|
||||
}, 60)
|
||||
if (!fromScratch) highlightChangedCells(touchedIds)
|
||||
})
|
||||
return () => diagram.setCommitHandler(null)
|
||||
}, [])
|
||||
|
||||
@@ -417,6 +417,22 @@ describe("MCP sync status", () => {
|
||||
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()
|
||||
@@ -649,6 +665,13 @@ describe("MCP sync export requests", () => {
|
||||
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,
|
||||
@@ -658,6 +681,129 @@ describe("MCP sync export requests", () => {
|
||||
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("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()
|
||||
@@ -874,13 +1020,13 @@ describe("decideLoad: one undo step on the page on screen, or a full load", () =
|
||||
{ mode: "load" },
|
||||
],
|
||||
[
|
||||
"the page on screen is unknown: the first page counts",
|
||||
"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: "commit", pageId: "p1" },
|
||||
{ mode: "load" },
|
||||
],
|
||||
] as [string, string, string | null, LoadDecision][])(
|
||||
"%s",
|
||||
@@ -982,7 +1128,7 @@ describe("MCP sync with server writes taken in place", () => {
|
||||
expect(t.next("POST").body.source).toBe("edit")
|
||||
})
|
||||
|
||||
it("after a restart, takes the server's diagram in place and keeps the tab's copy", async () => {
|
||||
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)
|
||||
@@ -996,25 +1142,24 @@ describe("MCP sync with server writes taken in place", () => {
|
||||
const poll = t.sync.poll()
|
||||
t.next("GET").answer(state("S2", 1, written))
|
||||
await poll
|
||||
expect(t.loads.at(-1)?.decision).toEqual({
|
||||
mode: "commit",
|
||||
pageId: "p1",
|
||||
})
|
||||
// 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",
|
||||
})
|
||||
// No load is awaited after a commit: the autosave of the committed
|
||||
// page is draw.io's copy of the write, and an edit after it is an edit
|
||||
t.sync.onAutoSave(
|
||||
file(
|
||||
drawioPage("p1", "One", cell("a", "A") + cell("c", "C", 300)) +
|
||||
drawioPage("p1", "One", cell("a", "A2")) +
|
||||
drawioPage("p2", "Two", cell("b", "B")),
|
||||
),
|
||||
)
|
||||
await t.settle()
|
||||
expect(t.posts()).toHaveLength(0)
|
||||
expect(t.next("POST").body.source).toBe("recover")
|
||||
t.sync.onDrawioLoad()
|
||||
await edit(
|
||||
t,
|
||||
file(
|
||||
@@ -1024,4 +1169,76 @@ describe("MCP sync with server writes taken in place", () => {
|
||||
)
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user