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:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 5ed8940b48
commit b6bd032c2c
5 changed files with 353 additions and 73 deletions
+5 -9
View File
@@ -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
View File
@@ -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
// ---------------------------------------------------------------------------
+90 -31
View File
@@ -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
+2 -9
View File
@@ -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)
}, [])
+228 -11
View File
@@ -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,
})
})
})