diff --git a/packages/mcp-server/src/edit-gate.ts b/packages/mcp-server/src/edit-gate.ts index 41bf766f..f80240b2 100644 --- a/packages/mcp-server/src/edit-gate.ts +++ b/packages/mcp-server/src/edit-gate.ts @@ -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)) diff --git a/packages/mcp-server/src/history.ts b/packages/mcp-server/src/history.ts index e317bf8c..9b597cf3 100644 --- a/packages/mcp-server/src/history.ts +++ b/packages/mcp-server/src/history.ts @@ -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() +// 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 } diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 7c08d5ac..86f07339 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -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 { diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 49c14a6e..5d131a52 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -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 , 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 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 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 } diff --git a/packages/mcp-server/src/new-diagram.ts b/packages/mcp-server/src/new-diagram.ts index 2443695d..f761a235 100644 --- a/packages/mcp-server/src/new-diagram.ts +++ b/packages/mcp-server/src/new-diagram.ts @@ -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`]*${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() + 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 } /** diff --git a/packages/mcp-server/src/pages.ts b/packages/mcp-server/src/pages.ts index 15a3a87c..a80ed43a 100644 --- a/packages/mcp-server/src/pages.ts +++ b/packages/mcp-server/src/pages.ts @@ -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)|]*>\s*[^\s<]/.test( xml, ) diff --git a/packages/mcp-server/src/persistence.ts b/packages/mcp-server/src/persistence.ts index 9568f651..123cd9b7 100644 --- a/packages/mcp-server/src/persistence.ts +++ b/packages/mcp-server/src/persistence.ts @@ -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() + /** 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 diff --git a/packages/mcp-server/src/preview/preview.js b/packages/mcp-server/src/preview/preview.js index 831826f1..8e6f3835 100644 --- a/packages/mcp-server/src/preview/preview.js +++ b/packages/mcp-server/src/preview/preview.js @@ -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)|]*>\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 diff --git a/packages/mcp-server/tests/edit-gate.test.ts b/packages/mcp-server/tests/edit-gate.test.ts index 88cb0a6d..66df1fce 100644 --- a/packages/mcp-server/tests/edit-gate.test.ts +++ b/packages/mcp-server/tests/edit-gate.test.ts @@ -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 = `${page("A", "a1")}` expect(markPageSeen("", live, { page_id: "A" })).toBe(live) }) }) diff --git a/packages/mcp-server/tests/http-server.test.ts b/packages/mcp-server/tests/http-server.test.ts index 6bae42e3..92ba8b40 100644 --- a/packages/mcp-server/tests/http-server.test.ts +++ b/packages/mcp-server/tests/http-server.test.ts @@ -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("5") }) + const page = (cellId: string) => + `` + + 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) => + `` + const cleared = `${emptyPage("Planning")}${emptyPage("Notes")}` + 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("", ''), + ) + expect(getHistory(id)).toHaveLength(1) + }) + it("keeps manual edits in history before restoring", async () => { const id = "mcp-history-manual" const doc = (cellId: string) => diff --git a/packages/mcp-server/tests/persistence.test.ts b/packages/mcp-server/tests/persistence.test.ts index 4accab26..30b6047b 100644 --- a/packages/mcp-server/tests/persistence.test.ts +++ b/packages/mcp-server/tests/persistence.test.ts @@ -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() diff --git a/packages/mcp-server/tests/wrap-cells.test.ts b/packages/mcp-server/tests/wrap-cells.test.ts index 81586c24..3d8378b8 100644 --- a/packages/mcp-server/tests/wrap-cells.test.ts +++ b/packages/mcp-server/tests/wrap-cells.test.ts @@ -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( + `dZHBDoIwDIafhjtsGPWM6MkTB8/LVmBxrGQMQZ/eLRuIUS/bv/VfmybF`, + ), + ).toBe(true) + }) + it("counts cells with spaces around the =", () => { expect(hasCells(``)).toBe(true) expect( @@ -106,6 +115,27 @@ describe("reservedIdError", () => { ``, ), ).toMatch(/"0" and "1"/) + // Other attributes ending in id, and id text inside a label + expect( + reservedIdError( + ``, + ), + ).toBeNull() + expect( + reservedIdError( + ``, + ), + ).toBeNull() + expect( + reservedIdError( + ``, + ), + ).toBeNull() + expect( + reservedIdError( + ``, + ), + ).toBeNull() // A whole model has its own root cells expect( reservedIdError(