From b6bd032c2cb116f9f81b825fbc0271372a841228 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 14:55:58 +0900 Subject: [PATCH] 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. --- components/chat/use-versions.ts | 14 +- lib/drawio/editor-bridge.ts | 41 ++-- packages/mcp-server/shell/mcp-sync-core.ts | 121 ++++++++--- packages/mcp-server/shell/use-mcp-sync.ts | 11 +- tests/unit/mcp-sync-core.test.ts | 239 ++++++++++++++++++++- 5 files changed, 353 insertions(+), 73 deletions(-) diff --git a/components/chat/use-versions.ts b/components/chat/use-versions.ts index 943aadbc..7ba21154 100644 --- a/components/chat/use-versions.ts +++ b/components/chat/use-versions.ts @@ -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 diff --git a/lib/drawio/editor-bridge.ts b/lib/drawio/editor-bridge.ts index 89f67506..53fe5073 100644 --- a/lib/drawio/editor-bridge.ts +++ b/lib/drawio/editor-bridge.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/packages/mcp-server/shell/mcp-sync-core.ts b/packages/mcp-server/shell/mcp-sync-core.ts index f5e5bcb7..84db2bef 100644 --- a/packages/mcp-server/shell/mcp-sync-core.ts +++ b/packages/mcp-server/shell/mcp-sync-core.ts @@ -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 | 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 | 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 { @@ -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 diff --git a/packages/mcp-server/shell/use-mcp-sync.ts b/packages/mcp-server/shell/use-mcp-sync.ts index 4918f40a..b3e90f09 100644 --- a/packages/mcp-server/shell/use-mcp-sync.ts +++ b/packages/mcp-server/shell/use-mcp-sync.ts @@ -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) }, []) diff --git a/tests/unit/mcp-sync-core.test.ts b/tests/unit/mcp-sync-core.test.ts index 1ce06ff8..6d9b1f43 100644 --- a/tests/unit/mcp-sync-core.test.ts +++ b/tests/unit/mcp-sync-core.test.ts @@ -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: "A2", 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("page 2 moved") + 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("A2 edited") }) + /** 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: "page 2 only", + }), + ) + await poll + await vi.advanceTimersByTimeAsync(600) + t.nextExport().answer({ data: "page 2" }) + 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, "A")) + 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: "page 2 only", + 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, "A3")) + await t.settle() + await t.settle() + expect(t.loads.at(-1)).toEqual({ + xml: "A3", + 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: "page 2 only", + }), + ) + 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: "page 2" }) + 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: "page 2 only", + }), + ) + await restore + expect(t.loads.at(-1)).toEqual({ + xml: "A", + 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: "thumb" }) + 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: "page 2 only", + }), + ) + 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: "" }) + await t.settle() + expect(t.next("POST").body).toMatchObject({ + xml: sized(827, 1169), + source: "edit", + baseVersion: 3, + }) + }) })