fix(mcp-server): recover sessions in one place, and more fixes from the third review

- A session whose state expired was recovered from its auto-save file only
  for the preview page; the tools built on their older copy and then
  overwrote the file. They now recover it first (restoreSavedSession).
- A preview tab that missed the last AI write pushed its older diagram
  over the recovered one after a restart. It now shows the recovered
  diagram and keeps its own copy in History.
- An empty record of what the model has seen (after load_diagram or a
  page tool on unseen changes) no longer lets one page of a multi-page
  document count for all, and get_diagram counts a page only once found.
- History: a thumbnail goes only to the entry it shows, the cached image
  never belongs to an older diagram, a re-serialized copy adds no entry,
  and a cleared document with its own pages is kept before a restore.
- The root cell id check reads attributes one by one: rack-id="1" or id
  text inside a label no longer counts.
- A compressed page counts as having cells; a saved file that could not
  be read is never written over.
This commit is contained in:
dayuan.jiang
2026-10-05 13:04:32 +09:00
parent 49468f9478
commit cfbf17f97e
12 changed files with 288 additions and 53 deletions
+12 -3
View File
@@ -120,10 +120,19 @@ export function markPageSeen(
liveXml: string,
selector: PageSelector,
): string {
if (!lastSeenXml) return liveXml
const otherPages = (xml: string) => {
const parse = (xml: string) => {
const normalized = normalizeToMxfile(xml)
const doc = normalized ? parseMxfile(normalized) : null
return normalized ? parseMxfile(normalized) : null
}
// An empty record also follows load_diagram and page tools that wrote
// over unseen changes, when the model may remember older pages: only
// a one-page document is then fully seen
if (!lastSeenXml) {
const pages = parse(liveXml)?.querySelectorAll("diagram").length ?? 1
return pages <= 1 ? liveXml : lastSeenXml
}
const otherPages = (xml: string) => {
const doc = parse(xml)
if (!doc) return null
findPageElement(doc, selector)?.element.remove()
return contentFingerprint(serializeMxfile(doc))
+23 -4
View File
@@ -3,6 +3,7 @@
* Stores {xml, svg} entries in a circular buffer
*/
import { contentFingerprint } from "./edit-gate.ts"
import { log } from "./logger.ts"
const MAX_HISTORY = 20
@@ -16,6 +17,14 @@ interface HistoryEntry {
let nextEntryId = 0
const historyStore = new Map<string, HistoryEntry[]>()
// The same pages and cells; a document without pages has an empty
// fingerprint and is compared as text only
function sameDiagram(a: string, b: string): boolean {
if (a === b) return true
const fingerprint = contentFingerprint(a)
return fingerprint !== "" && fingerprint === contentFingerprint(b)
}
export function addHistory(sessionId: string, xml: string, svg = ""): number {
let history = historyStore.get(sessionId)
if (!history) {
@@ -23,9 +32,11 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
historyStore.set(sessionId, history)
}
// Dedupe: skip if same as last entry
// Dedupe: skip if same as last entry, also when only re-serialized
// (the browser's copy of the same diagram)
const last = history[history.length - 1]
if (last?.xml === xml) {
if (last && sameDiagram(last.xml, xml)) {
if (svg && !last.svg) last.svg = svg
return history.length - 1
}
@@ -56,11 +67,19 @@ export function clearHistory(sessionId: string): void {
historyStore.delete(sessionId)
}
export function updateLastHistorySvg(sessionId: string, svg: string): boolean {
/**
* Give the last entry the image the browser took of shownXml, the diagram
* it just loaded, when that entry is this diagram
*/
export function updateLastHistorySvg(
sessionId: string,
svg: string,
shownXml: string,
): boolean {
const history = historyStore.get(sessionId)
if (!history || history.length === 0) return false
const last = history[history.length - 1]
if (!last.svg) {
if (!last.svg && sameDiagram(last.xml, shownXml)) {
last.svg = svg
return true
}
+32 -7
View File
@@ -32,6 +32,7 @@ function readBody(
req.on("end", () => cb(Buffer.concat(chunks).toString("utf8")))
}
import { contentFingerprint } from "./edit-gate.ts"
import {
addHistory,
clearHistory,
@@ -40,7 +41,7 @@ import {
updateLastHistorySvg,
} from "./history.ts"
import { log } from "./logger.ts"
import { BLANK_MXFILE, hasCells } from "./pages.ts"
import { BLANK_MXFILE } from "./pages.ts"
// Configurable draw.io embed URL for private deployments
const DRAWIO_BASE_URL =
@@ -82,17 +83,28 @@ function getMostRecentSessionId(): string | null {
return mostRecent?.id || null
}
/**
* Give a session whose state is gone (it expired, or the MCP process
* restarted) its auto-saved diagram back. The MCP tools call this before
* they read the state, so they never build on an older copy and then
* overwrite the file. Not a change worth saving again.
*/
export function restoreSavedSession(sessionId: string): void {
if (stateStore.has(sessionId) || !isValidSessionId(sessionId)) return
const saved = savedStateLoader?.(sessionId)
if (saved) setState(sessionId, saved, undefined, false, false)
}
function ensureSessionStateInitialized(sessionId: string): void {
if (!sessionId) return
if (!isValidSessionId(sessionId)) return
restoreSavedSession(sessionId)
if (stateStore.has(sessionId)) return
// The session's saved diagram, so a blank page never replaces that file.
// Not a change worth saving: the browser fills it on its next push
// A blank diagram keeps the draw.io spinner (spin=1) from waiting
// forever when no load(xml) is ever sent
const saved = savedStateLoader?.(sessionId)
setState(sessionId, saved || BLANK_MXFILE, undefined, false, false)
setState(sessionId, BLANK_MXFILE, undefined, false, false)
}
interface SessionState {
@@ -169,7 +181,9 @@ export function setState(
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
lastUpdated: new Date(),
lastPolled: existing?.lastPolled,
svg: svg || existing?.svg, // Preserve cached SVG if not provided
// The image of this XML, never an older one's: a write without an
// image (AI write, sync reply) leaves none until the browser sends it
svg: svg || undefined,
syncRequested: undefined, // Clear sync request when browser pushes state
exportFormat: existing?.exportFormat, // Preserve pending export request
exportXml: existing?.exportXml, // Preserve pending projection
@@ -584,8 +598,14 @@ function handleRestoreApi(
// Edits in the browser since the last entry are not in history
// yet: keep them, so the restore can be undone
// (any state besides a blank page; a cleared document with its
// own pages counts)
const current = stateStore.get(sessionId)
if (current && hasCells(current.xml)) {
if (
current &&
contentFingerprint(current.xml) !==
contentFingerprint(BLANK_MXFILE)
) {
addHistory(sessionId, current.xml, current.svg)
}
const newVersion = setState(sessionId, entry.xml)
@@ -621,7 +641,12 @@ function handleHistorySvgApi(
return
}
updateLastHistorySvg(sessionId, svg)
// The browser took it of the diagram it just loaded: the state
const state = stateStore.get(sessionId)
if (state) {
updateLastHistorySvg(sessionId, svg, state.xml)
state.svg = svg
}
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true }))
} catch {
+41 -22
View File
@@ -38,6 +38,7 @@ import {
onStateChange,
requestExport,
requestSync,
restoreSavedSession,
setState,
shutdown,
startHttpServer,
@@ -78,6 +79,21 @@ const autosaver = new Autosaver(defaultDataDir())
onStateChange((sessionId, xml) => autosaver.schedule(sessionId, xml))
onSessionRecreate((sessionId) => autosaver.load(sessionId))
// A one-page view that does not count for the whole document (edit-gate.ts)
const OTHER_PAGES_UNSEEN =
"You have not seen the other pages in their current state."
/**
* The browser's state of a session. After it expired (or the process
* restarted) the saved file comes back first, so a tool never builds on an
* older copy and then overwrites the file. Call it before requestSync or
* requestExport, which need the state.
*/
function sessionState(sessionId: string) {
restoreSavedSession(sessionId)
return getState(sessionId)
}
// Session state (single session for simplicity)
let currentSession: {
id: string
@@ -353,7 +369,7 @@ Rules: cells are siblings (never nested), ids are unique per page and start from
log.info(`Setting diagram content, ${xml.length} chars`)
// Sync from browser state first
const browserState = getState(currentSession.id)
const browserState = sessionState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
}
@@ -481,7 +497,7 @@ server.registerTool(
// Save the user's current state before replacing (same flow as
// create_new_diagram).
const browserState = getState(currentSession.id)
const browserState = sessionState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
}
@@ -605,7 +621,7 @@ server.registerTool(
// embed/sync path can hand back a bare <mxGraphModel>, and adopting
// it verbatim would silently strip a multi-page document down to
// one page on the next write.
const browserState = getState(currentSession.id)
const browserState = sessionState(currentSession.id)
if (browserState?.xml) {
currentSession.xml =
normalizeToMxfile(browserState.xml) ?? browserState.xml
@@ -660,7 +676,7 @@ server.registerTool(
const next =
currentSession.lastSeenXml === liveXml
? "Build your operations on this XML and retry."
: "Other pages changed too: call get_diagram without a page selector, then retry."
: `${OTHER_PAGES_UNSEEN} Call get_diagram without a page selector, then retry.`
return {
content: [
{
@@ -775,8 +791,10 @@ server.registerTool(
}
}
// Request browser to push fresh state and wait for it
// Request browser to push fresh state and wait for it (an
// expired session first gets its saved file back to sync)
let staleNote = ""
restoreSavedSession(currentSession.id)
const syncRequested = requestSync(currentSession.id)
if (syncRequested) {
const synced = await waitForSync(currentSession.id)
@@ -790,7 +808,7 @@ server.registerTool(
// Fetch latest state from browser, re-normalising to mxfile so a
// bare <mxGraphModel> pushed back by the embed/sync path doesn't
// strip page structure (see edit_diagram for the same guard).
const browserState = getState(currentSession.id)
const browserState = sessionState(currentSession.id)
if (browserState?.xml) {
currentSession.xml =
normalizeToMxfile(browserState.xml) ?? browserState.xml
@@ -816,19 +834,7 @@ server.registerTool(
// The model is now looking at the current state. Record the raw
// store value — the gate's fast path is plain string equality
// against the store, with a structural comparison as fallback.
// One page shown counts for all only if the others are unchanged.
const liveXml = browserState?.xml || currentSession.xml
currentSession.lastSeenXml = hasPageSelector(pageSelector)
? markPageSeen(
currentSession.lastSeenXml,
liveXml,
pageSelector,
)
: liveXml
const otherPagesNote =
currentSession.lastSeenXml === liveXml
? ""
: "\n\nNote: other pages changed since you last saw them. Call get_diagram without a page selector before editing."
const doc = parseMxfile(currentSession.xml)
const pages = doc ? listPagesFromDoc(doc) : []
const pageList = pages.length
@@ -837,6 +843,7 @@ server.registerTool(
// No selector → return full mxfile
if (!hasPageSelector(pageSelector)) {
currentSession.lastSeenXml = liveXml
return {
content: [
{
@@ -863,6 +870,17 @@ server.registerTool(
isError: true,
}
}
// One page shown counts for all only if the others are as the
// model saw them last
currentSession.lastSeenXml = markPageSeen(
currentSession.lastSeenXml,
liveXml,
pageSelector,
)
const otherPagesNote =
currentSession.lastSeenXml === liveXml
? ""
: `\n\nNote: ${OTHER_PAGES_UNSEEN} Call get_diagram without a page selector before editing.`
return {
content: [
{
@@ -1002,7 +1020,8 @@ server.registerTool(
if (previewStalled(currentSession.id)) {
return previewStalledError(currentSession.id)
}
const xml = getState(currentSession.id)?.xml || currentSession.xml
const xml =
sessionState(currentSession.id)?.xml || currentSession.xml
if (!hasCells(xml)) {
return {
content: [{ type: "text", text: "The diagram is empty." }],
@@ -1131,7 +1150,7 @@ server.registerTool(
// Fetch latest state, re-normalised to mxfile so a page
// selector works on a bare <mxGraphModel> pushed by the browser
const browserState = getState(currentSession.id)
const browserState = sessionState(currentSession.id)
if (browserState?.xml) {
currentSession.xml =
normalizeToMxfile(browserState.xml) ?? browserState.xml
@@ -1229,7 +1248,7 @@ server.registerTool(
const browserFormat =
detectedFormat === "drawio.svg" ? "xmlsvg" : detectedFormat
const state = getState(currentSession.id)
const state = sessionState(currentSession.id)
if (!state) {
return {
content: [
@@ -1363,7 +1382,7 @@ async function loadMxfileForMutation(): Promise<
}
}
// Pull latest from browser so we don't clobber autosaved changes.
const browserState = getState(currentSession.id)
const browserState = sessionState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
}
+23 -6
View File
@@ -16,14 +16,31 @@ export type NewDiagram =
* or null.
*/
export function reservedIdError(input: string): string | null {
const ID = String.raw`\bid\s*=\s*["'][01]["']`
const shape = new RegExp(
String.raw`<mxCell\b(?=[^>]*${ID})(?=[^>]*\b(?:vertex|edge)\s*=\s*["']1["'])|<(?:UserObject|object)\b[^>]*${ID}`,
if (/<(mxGraphModel|mxfile)\b/.test(input)) return null
// Each opening tag with its attributes; quoted values are read as a
// whole, so text such as label="id='1'" is not an attribute
const tags = input.matchAll(
/<(mxCell|UserObject|object)\b((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*\/?>/g,
)
if (/<(mxGraphModel|mxfile)\b/.test(input) || !shape.test(input)) {
return null
for (const [, tag, attrText] of tags) {
const attrs = new Map<string, string>()
for (const [, name, double, single] of attrText.matchAll(
/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
)) {
attrs.set(name, double ?? single)
}
const id = attrs.get("id")
if (id !== "0" && id !== "1") continue
// A wrapper's id is its cell's; an mxCell counts as a shape or edge
if (
tag !== "mxCell" ||
attrs.get("vertex") === "1" ||
attrs.get("edge") === "1"
) {
return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".'
}
}
return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".'
return null
}
/**
+5 -2
View File
@@ -52,9 +52,12 @@ export function generatePageId(): string {
return `${a}-${b}`
}
/** Any cell besides the root cells "0" and "1" */
/**
* Any cell besides the root cells "0" and "1", or a page in draw.io's
* compressed format (text instead of a model), which is not checked further
*/
export const hasCells = (xml: string) =>
/<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)/.test(
/<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)|<diagram\b[^>]*>\s*[^\s<]/.test(
xml,
)
+8 -2
View File
@@ -55,13 +55,19 @@ export class Autosaver {
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
}
// Saved files that could not be read back: never written over, since
// the session then shows something else than what they hold
private unreadable = new Set<string>()
/** The session's saved diagram, or null. */
load(sessionId: string): string | null {
const path = this.pathFor(sessionId)
if (!path || !existsSync(path)) return null
try {
return readFileSync(path, "utf-8")
} catch {
} catch (error) {
log.warn(`Could not read the saved diagram ${path}: ${error}`)
this.unreadable.add(path)
return null
}
}
@@ -87,7 +93,7 @@ export class Autosaver {
const entry = this.pending.get(sessionId)
this.pending.delete(sessionId)
const path = this.pathFor(sessionId)
if (!entry || !this.dir || !path) return
if (!entry || !this.dir || !path || this.unreadable.has(path)) return
try {
const isNew = !existsSync(path)
// A blank page the browser shows before any drawing: nothing to keep
+24 -5
View File
@@ -144,7 +144,14 @@ function showNotice(text) {
noticeTimer = setTimeout(() => el.classList.remove('open'), 8000);
}
// source is 'sync' for replies to a server sync request, else 'edit'
// Same rule as hasCells in pages.ts: a cell besides the root cells, or a
// compressed page
function hasCells(xml) {
return /<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)|<diagram\b[^>]*>\s*[^\s<]/.test(xml || '');
}
// source is 'sync' for replies to a server sync request, 'recover' for the
// tab's copy after the server recovered the session, else 'edit'
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit') {
if (!sessionId) return;
try {
@@ -157,7 +164,11 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
// 409: the AI wrote a newer version; load it now
else if (r.status === 409) {
const d = await r.json().catch(() => ({}));
if (d.savedToHistory) showNotice('The AI changed the diagram while you were editing. Your last change was saved in History.');
if (d.savedToHistory) {
showNotice(source === 'recover'
? 'The diagram was restored from its saved file. What this tab showed before is in History.'
: 'The AI changed the diagram while you were editing. Your last change was saved in History.');
}
poll();
}
} catch (e) { console.error('Push failed:', e); }
@@ -174,10 +185,18 @@ async function poll() {
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
if (!r.ok) return;
const s = await r.json();
// The server lost this session (e.g. it expired) and rebuilt it
// with a blank diagram: push back what the browser shows.
// The server lost this session (it expired, or the MCP process
// restarted) and rebuilt it. Blank: push back what the browser
// shows. From the auto-save file, which can hold an AI write this
// tab never loaded: show that, and keep this tab's copy in History
// (a push based on version 0 is refused and saved there).
if (s.version < knownVersion && lastXml) {
pushState(lastXml);
if (!hasCells(s.xml)) {
pushState(lastXml);
} else {
currentVersion = 0;
if (s.xml !== lastXml) pushState(lastXml, '', 0, 'recover');
}
}
// Load new diagram from server (before export, so we export latest).
// While a page-targeted projection is on screen, only the restore
+8 -2
View File
@@ -156,9 +156,15 @@ describe("markPageSeen", () => {
expect(checkEditGate(marked, live).ok).toBe(false)
})
it("counts everything as seen when the model saw nothing before", () => {
// It has no old copy of the other pages to edit from
it("does not count other pages when the record of what was seen is empty", () => {
// Empty also after load_diagram or a page tool on unseen changes,
// when the model may still remember an older copy of the pages
const live = doc("a1", "b1")
expect(markPageSeen("", live, { page_id: "A" })).toBe("")
})
it("counts a one-page document as seen from its only page", () => {
const live = `<mxfile>${page("A", "a1")}</mxfile>`
expect(markPageSeen("", live, { page_id: "A" })).toBe(live)
})
})
@@ -8,6 +8,7 @@
import http from "node:http"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
import { addHistory, getHistory } from "../src/history.ts"
import {
getState,
@@ -23,6 +24,8 @@ import {
let port = 0
beforeAll(async () => {
// XML parsing, as the server installs it at startup
installDomPolyfill()
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
})
@@ -350,6 +353,67 @@ describe("history restore", () => {
expect(getState(id)?.xml).toBe("<mxfile>5</mxfile>")
})
const page = (cellId: string) =>
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
it("gives a thumbnail only to the entry it shows", async () => {
const id = "mcp-history-thumb"
setState(id, page("shown"))
// The last entry is another diagram (a tab's copy kept on recovery)
addHistory(id, page("other"))
await postJson("/api/history-svg", {
sessionId: id,
svg: "SVG-OF-SHOWN",
})
expect(getHistory(id).at(-1)?.svg).toBe("")
addHistory(id, page("shown"))
await postJson("/api/history-svg", {
sessionId: id,
svg: "SVG-OF-SHOWN",
})
expect(getHistory(id).at(-1)?.svg).toBe("SVG-OF-SHOWN")
})
it("never pairs the image of an older diagram with a newer one", async () => {
const id = "mcp-history-stale-svg"
const version = setState(id, page("user"))
await postJson("/api/state", {
sessionId: id,
xml: page("user2"),
svg: "SVG-OF-USER2",
baseVersion: version,
})
// An AI write without an image of its own
setState(id, page("ai"))
addHistory(id, page("older"))
const [entry] = getHistory(id)
await postJson("/api/restore", { sessionId: id, id: entry.id })
const kept = getHistory(id).find((e) => e.xml === page("ai"))
expect(kept?.svg).toBe("")
})
it("keeps a cleared document with renamed pages before restoring", async () => {
const id = "mcp-history-empty-pages"
const emptyPage = (name: string) =>
`<diagram id="${name}" name="${name}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram>`
const cleared = `<mxfile>${emptyPage("Planning")}${emptyPage("Notes")}</mxfile>`
addHistory(id, page("before"))
setState(id, cleared, undefined, true)
const [entry] = getHistory(id)
await postJson("/api/restore", { sessionId: id, id: entry.id })
expect(getHistory(id).map((e) => e.xml)).toContain(cleared)
})
it("adds no entry for a re-serialized copy of the last one", () => {
const id = "mcp-history-dedupe"
addHistory(id, page("same"))
addHistory(
id,
page("same").replace("<mxGraphModel>", '<mxGraphModel dx="10">'),
)
expect(getHistory(id)).toHaveLength(1)
})
it("keeps manual edits in history before restoring", async () => {
const id = "mcp-history-manual"
const doc = (cellId: string) =>
@@ -3,6 +3,7 @@
*/
import {
chmodSync,
existsSync,
mkdtempSync,
readdirSync,
@@ -84,6 +85,23 @@ describe("Autosaver", () => {
expect(new Autosaver(null).load("mcp-back")).toBeNull()
})
it("never replaces a saved file it could not read", () => {
const saver = new Autosaver(tempDir(), 10)
saver.schedule("mcp-locked", DIAGRAM)
saver.flush()
const path = saver.pathFor("mcp-locked") as string
chmodSync(path, 0o000)
try {
expect(saver.load("mcp-locked")).toBeNull()
// The blank page shown instead must not overwrite the diagram
saver.schedule("mcp-locked", BLANK)
saver.flush()
} finally {
chmodSync(path, 0o644)
}
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
})
it("does nothing when saving is off", () => {
const saver = new Autosaver(null)
expect(saver.pathFor("mcp-x")).toBeNull()
@@ -85,6 +85,15 @@ describe("hasCells", () => {
)
})
it("counts a compressed page as having cells", () => {
// draw.io's compressed format: the page's model is text
expect(
hasCells(
`<mxfile><diagram id="p" name="P">dZHBDoIwDIafhjtsGPWM6MkTB8/LVmBxrGQMQZ/eLRuIUS/bv/VfmybF</diagram></mxfile>`,
),
).toBe(true)
})
it("counts cells with spaces around the =", () => {
expect(hasCells(`<mxCell id = "a" vertex="1" parent="1"/>`)).toBe(true)
expect(
@@ -106,6 +115,27 @@ describe("reservedIdError", () => {
`<UserObject id="1" label="Docs"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>`,
),
).toMatch(/"0" and "1"/)
// Other attributes ending in id, and id text inside a label
expect(
reservedIdError(
`<UserObject label="Server" rack-id="1" id="7"><mxCell vertex="1" parent="1"/></UserObject>`,
),
).toBeNull()
expect(
reservedIdError(
`<UserObject id="u" label="Example: id='1'"><mxCell vertex="1" parent="1"/></UserObject>`,
),
).toBeNull()
expect(
reservedIdError(
`<mxCell id="5" data-id="0" vertex="1" parent="1"/>`,
),
).toBeNull()
expect(
reservedIdError(
`<mxCell id="5" value="id=&quot;1&quot; vertex=&quot;1&quot;" vertex="1" parent="1"/>`,
),
).toBeNull()
// A whole model has its own root cells
expect(
reservedIdError(