mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-07 10:17:47 +08:00
fix(mcp-server): count a one-page view only for that page, and more review fixes
Found by the second PR review: - get_diagram with a page selector, or a rejected edit's error, counted the whole document as seen, so an edit on another page could overwrite the user's change there. A one-page view now counts for all pages only if the others are unchanged; otherwise the reply says to get them. - add_page accepted shapes with the root cell ids "0" and "1" and renamed them, breaking their edges. The check also missed ids on UserObject wrappers and ids written with spaces around the "=". - Root cells written over two lines were kept as an extra layer, cells with id = "a" did not count as cells, and CDATA text before a page's model passed the check although draw.io cannot open the page. - Auto-save cleanup deleted the user's own files that start with mcp-. Only names in the session id format are removed now. - Restoring a history entry dropped edits made in the browser since the last entry. They are added to history first. - A session whose state expired showed a blank page, and the next change overwrote its auto-save file. The saved file is loaded instead. - An edit on a page export's one-page projection, made before the real document was back, replaced the whole document. - A late sync reply could overwrite a newer edit: each sync export is numbered, and the server ignores replies older than the current state. - screenshot_diagram could return another session's image after start_session ran during its retries.
This commit is contained in:
@@ -17,7 +17,14 @@
|
||||
* change: the set of pages, each page's name, and each page's cell tree
|
||||
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
|
||||
*/
|
||||
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.ts"
|
||||
import {
|
||||
findPageElement,
|
||||
isMxGraphModel,
|
||||
normalizeToMxfile,
|
||||
type PageSelector,
|
||||
parseMxfile,
|
||||
serializeMxfile,
|
||||
} from "./pages.ts"
|
||||
|
||||
export type EditGateResult =
|
||||
| { ok: true }
|
||||
@@ -100,3 +107,29 @@ export function checkEditGate(
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* The model was shown only the selected page of liveXml (get_diagram with a
|
||||
* page selector, or a rejected edit's error). It has seen the whole document
|
||||
* if the other pages are as it last saw them, or if it saw nothing before
|
||||
* (it then has no old copy of them to edit from). Returns the new
|
||||
* lastSeenXml: liveXml, or lastSeenXml unchanged.
|
||||
*/
|
||||
export function markPageSeen(
|
||||
lastSeenXml: string,
|
||||
liveXml: string,
|
||||
selector: PageSelector,
|
||||
): string {
|
||||
if (!lastSeenXml) return liveXml
|
||||
const otherPages = (xml: string) => {
|
||||
const normalized = normalizeToMxfile(xml)
|
||||
const doc = normalized ? parseMxfile(normalized) : null
|
||||
if (!doc) return null
|
||||
findPageElement(doc, selector)?.element.remove()
|
||||
return contentFingerprint(serializeMxfile(doc))
|
||||
}
|
||||
const before = otherPages(lastSeenXml)
|
||||
return before !== null && before === otherPages(liveXml)
|
||||
? liveXml
|
||||
: lastSeenXml
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
updateLastHistorySvg,
|
||||
} from "./history.ts"
|
||||
import { log } from "./logger.ts"
|
||||
import { BLANK_MXFILE } from "./pages.ts"
|
||||
import { BLANK_MXFILE, hasCells } from "./pages.ts"
|
||||
|
||||
// Configurable draw.io embed URL for private deployments
|
||||
const DRAWIO_BASE_URL =
|
||||
@@ -87,10 +87,12 @@ function ensureSessionStateInitialized(sessionId: string): void {
|
||||
if (!isValidSessionId(sessionId)) return
|
||||
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
|
||||
setState(sessionId, BLANK_MXFILE, undefined, false, false)
|
||||
const saved = savedStateLoader?.(sessionId)
|
||||
setState(sessionId, saved || BLANK_MXFILE, undefined, false, false)
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
@@ -142,6 +144,16 @@ export function onStateChange(
|
||||
stateListener = listener
|
||||
}
|
||||
|
||||
// Reads a session's saved diagram when its state is created again (it
|
||||
// expired, or the MCP process restarted)
|
||||
let savedStateLoader: ((sessionId: string) => string | null) | null = null
|
||||
|
||||
export function onSessionRecreate(
|
||||
loader: (sessionId: string) => string | null,
|
||||
): void {
|
||||
savedStateLoader = loader
|
||||
}
|
||||
|
||||
export function setState(
|
||||
sessionId: string,
|
||||
xml: string,
|
||||
@@ -461,11 +473,15 @@ function handleStateApi(
|
||||
|
||||
// The browser edited a version older than the latest AI write
|
||||
// (it has not loaded that write yet). Keep the AI write; the
|
||||
// browser loads it on its next poll.
|
||||
// browser loads it on its next poll. A sync reply is also
|
||||
// stale after a newer write of the browser's own (a user
|
||||
// edit saved while the export ran).
|
||||
const current = stateStore.get(sessionId)
|
||||
if (
|
||||
typeof data.baseVersion === "number" &&
|
||||
data.baseVersion < (current?.serverVersion ?? 0)
|
||||
(data.baseVersion < (current?.serverVersion ?? 0) ||
|
||||
(data.source === "sync" &&
|
||||
data.baseVersion < (current?.version ?? 0)))
|
||||
) {
|
||||
let savedToHistory = false
|
||||
if (data.source === "sync") {
|
||||
@@ -566,6 +582,12 @@ function handleRestoreApi(
|
||||
return
|
||||
}
|
||||
|
||||
// Edits in the browser since the last entry are not in history
|
||||
// yet: keep them, so the restore can be undone
|
||||
const current = stateStore.get(sessionId)
|
||||
if (current && hasCells(current.xml)) {
|
||||
addHistory(sessionId, current.xml, current.svg)
|
||||
}
|
||||
const newVersion = setState(sessionId, entry.xml)
|
||||
addHistory(sessionId, entry.xml, entry.svg)
|
||||
|
||||
|
||||
@@ -27,13 +27,14 @@ import type { DiagramOperation } from "./diagram-operations.ts"
|
||||
import { installDomPolyfill } from "./dom.ts"
|
||||
import { DRAWING_GUIDE } from "./drawing-guide.ts"
|
||||
import { editDiagram, targetPageXml } from "./edit-diagram.ts"
|
||||
import { checkEditGate } from "./edit-gate.ts"
|
||||
import { checkEditGate, markPageSeen } from "./edit-gate.ts"
|
||||
import { addHistory } from "./history.ts"
|
||||
import {
|
||||
type ExportFormat,
|
||||
type ExportOptions,
|
||||
getServerPort,
|
||||
getState,
|
||||
onSessionRecreate,
|
||||
onStateChange,
|
||||
requestExport,
|
||||
requestSync,
|
||||
@@ -44,7 +45,7 @@ import {
|
||||
} from "./http-server.ts"
|
||||
import { parseDrawioFileContent } from "./load-diagram.ts"
|
||||
import { log } from "./logger.ts"
|
||||
import { prepareNewDiagram } from "./new-diagram.ts"
|
||||
import { prepareNewDiagram, reservedIdError } from "./new-diagram.ts"
|
||||
import {
|
||||
addPageToDoc,
|
||||
deletePageFromDoc,
|
||||
@@ -75,6 +76,7 @@ const config = {
|
||||
// Keep each session's latest diagram on disk, so it survives this process
|
||||
const autosaver = new Autosaver(defaultDataDir())
|
||||
onStateChange((sessionId, xml) => autosaver.schedule(sessionId, xml))
|
||||
onSessionRecreate((sessionId) => autosaver.load(sessionId))
|
||||
|
||||
// Session state (single session for simplicity)
|
||||
let currentSession: {
|
||||
@@ -643,18 +645,27 @@ server.registerTool(
|
||||
: "edit_diagram rejected: the model has not seen the diagram yet",
|
||||
)
|
||||
// The error carries the current page, so the model has now
|
||||
// seen it and can retry without a get_diagram round-trip.
|
||||
currentSession.lastSeenXml =
|
||||
browserState?.xml || currentSession.xml
|
||||
// seen it and can retry without a get_diagram round-trip,
|
||||
// unless other pages changed too.
|
||||
const liveXml = browserState?.xml || currentSession.xml
|
||||
currentSession.lastSeenXml = markPageSeen(
|
||||
currentSession.lastSeenXml,
|
||||
liveXml,
|
||||
pageSelector,
|
||||
)
|
||||
const reason =
|
||||
gate.reason === "stale"
|
||||
? "The diagram changed in the browser since you last saw it (e.g. manual user edits). No changes were made."
|
||||
: "You have not seen this diagram yet, so no changes were made."
|
||||
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."
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\nBuild your operations on this XML and retry.`,
|
||||
text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\n${next}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
@@ -796,16 +807,28 @@ 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.
|
||||
currentSession.lastSeenXml = browserState?.xml || currentSession.xml
|
||||
|
||||
const pageSelector = pickPageSelector({
|
||||
page_id,
|
||||
page_name,
|
||||
page_index,
|
||||
})
|
||||
|
||||
// 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
|
||||
@@ -844,7 +867,7 @@ server.registerTool(
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Page ${projection.index} ("${projection.name}"):\n\n${projection.xml}\n\n${pageList}${staleNote}`,
|
||||
text: `Page ${projection.index} ("${projection.name}"):\n\n${projection.xml}\n\n${pageList}${staleNote}${otherPagesNote}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1016,13 +1039,13 @@ server.registerTool(
|
||||
}
|
||||
|
||||
let data: string | undefined
|
||||
// start_session may replace currentSession between the tries
|
||||
const sessionId = currentSession.id
|
||||
for (const width of SCREENSHOT_WIDTHS) {
|
||||
data = await exportViaBrowser(
|
||||
currentSession.id,
|
||||
"png",
|
||||
projectionXml,
|
||||
{ width, pageId },
|
||||
)
|
||||
data = await exportViaBrowser(sessionId, "png", projectionXml, {
|
||||
width,
|
||||
pageId,
|
||||
})
|
||||
if (!data || data.length <= MAX_SCREENSHOT_CHARS) break
|
||||
}
|
||||
if (!data) {
|
||||
@@ -1502,6 +1525,13 @@ server.registerTool(
|
||||
|
||||
// If caller provided XML, validate it before splicing it in so we
|
||||
// never get a half-broken mxfile written to the session.
|
||||
const reserved = xml && reservedIdError(xml)
|
||||
if (reserved) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${reserved}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
let cleanXml: string | undefined = xml && wrapCellsInModel(xml)
|
||||
if (cleanXml) {
|
||||
const { valid, error, fixed, fixes } =
|
||||
|
||||
@@ -9,6 +9,23 @@ export type NewDiagram =
|
||||
| { ok: true; xml: string; fixes: string[] }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Bare cells get the root cells "0" and "1". A shape or edge with one of
|
||||
* these ids would be renamed as a duplicate, breaking its edges. Its id may
|
||||
* be on a <UserObject> or <object> wrapper. Returns the error for the model,
|
||||
* 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) || !shape.test(input)) {
|
||||
return null
|
||||
}
|
||||
return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".'
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare cells get the wrapper and root cells first, since the strict parser
|
||||
* rejects several top-level elements. Then the XML is validated and
|
||||
@@ -19,19 +36,8 @@ export function prepareNewDiagram(
|
||||
input: string,
|
||||
page: { pageId?: string; pageName?: string } = {},
|
||||
): NewDiagram {
|
||||
// Bare cells get the root cells "0" and "1". A shape or edge with one of
|
||||
// these ids would be renamed as a duplicate, breaking its edges.
|
||||
if (
|
||||
!/<(mxGraphModel|mxfile)\b/.test(input) &&
|
||||
/<mxCell\b(?=[^>]*\bid=["'][01]["'])(?=[^>]*\b(?:vertex|edge)=["']1["'])/.test(
|
||||
input,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".',
|
||||
}
|
||||
}
|
||||
const reserved = reservedIdError(input)
|
||||
if (reserved) return { ok: false, error: reserved }
|
||||
let xml = wrapCellsInModel(input)
|
||||
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
|
||||
if (fixed) xml = fixed
|
||||
|
||||
@@ -54,7 +54,9 @@ export function generatePageId(): string {
|
||||
|
||||
/** Any cell besides the root cells "0" and "1" */
|
||||
export const hasCells = (xml: string) =>
|
||||
/<(mxCell\b[^>]*\bid=["'](?![01]["'])|UserObject\b|object\b)/.test(xml)
|
||||
/<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)/.test(
|
||||
xml,
|
||||
)
|
||||
|
||||
/** Cheap regex check — does the XML start with an <mxfile> root? */
|
||||
export function isMxFile(xml: string): boolean {
|
||||
@@ -121,8 +123,14 @@ export function wrapCellsInModel(xml: string): string {
|
||||
content = content.slice(0, end)
|
||||
}
|
||||
content = content
|
||||
.replace(/<mxCell[^>]*\bid=["']0["'][^>]*(?:\/>|><\/mxCell>)/g, "")
|
||||
.replace(/<mxCell[^>]*\bid=["']1["'][^>]*(?:\/>|><\/mxCell>)/g, "")
|
||||
.replace(
|
||||
/<mxCell[^>]*\bid\s*=\s*["']0["'][^>]*(?:\/>|>\s*<\/mxCell>)/g,
|
||||
"",
|
||||
)
|
||||
.replace(
|
||||
/<mxCell[^>]*\bid\s*=\s*["']1["'][^>]*(?:\/>|>\s*<\/mxCell>)/g,
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
return `<mxGraphModel><root>${ROOT_CELLS}${content}</root></mxGraphModel>`
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
@@ -54,6 +55,17 @@ export class Autosaver {
|
||||
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
schedule(sessionId: string, xml: string): void {
|
||||
if (!this.dir) return
|
||||
const previous = this.pending.get(sessionId)
|
||||
@@ -93,10 +105,11 @@ export class Autosaver {
|
||||
private removeOldest(): void {
|
||||
if (!this.dir) return
|
||||
const dir = this.dir
|
||||
// Only our own session files: DRAWIO_DATA_DIR may be a folder
|
||||
// that also holds the user's diagrams
|
||||
// Only our own session files (mcp-<time in base 36>-<random>, made
|
||||
// by start_session): DRAWIO_DATA_DIR may be a folder that also holds
|
||||
// the user's diagrams
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => f.startsWith("mcp-") && f.endsWith(".drawio"))
|
||||
.filter((f) => /^mcp-[0-9a-z]{8}-[0-9a-z]{1,6}\.drawio$/.test(f))
|
||||
.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
for (const { f } of files.slice(this.maxFiles)) {
|
||||
|
||||
@@ -32,8 +32,9 @@ window.addEventListener('message', (e) => {
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase); pendingSvgExport = null; } }, 2000);
|
||||
} else if (msg.event === 'export' && msg.format === 'xml') {
|
||||
// Sync export requested by the server (get_diagram).
|
||||
// draw.io returns the XML in msg.xml, with no msg.data.
|
||||
if (pendingSyncExport && msg.xml) {
|
||||
// draw.io returns the XML in msg.xml, with no msg.data. A late
|
||||
// reply to an earlier request was taken at another version.
|
||||
if (pendingSyncExport && msg.xml && msg.message?.syncExport === syncExportSeq) {
|
||||
pendingSyncExport = false;
|
||||
// Push with the version the export was taken at: a
|
||||
// newer AI write may have loaded meanwhile, and this
|
||||
@@ -127,9 +128,10 @@ function loadDiagram(xml, capturePreview = false) {
|
||||
// autosave that was still in flight when the projection started,
|
||||
// which a copy taken at that moment would miss. A flag is used
|
||||
// because a push finishing meanwhile may update currentVersion.
|
||||
// projectionExportActive stays set until the poll loads the document:
|
||||
// an edit on the projection before that must not be pushed.
|
||||
function restoreFromProjection() {
|
||||
if (!projectionExportActive) return;
|
||||
projectionExportActive = false;
|
||||
forceReload = true;
|
||||
poll();
|
||||
}
|
||||
@@ -163,6 +165,7 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
|
||||
|
||||
let pendingSyncExport = false;
|
||||
let pendingSyncBase = 0; // version the pending sync export was taken at
|
||||
let syncExportSeq = 0; // number of the latest sync export
|
||||
|
||||
async function poll() {
|
||||
if (!sessionId) return;
|
||||
@@ -177,12 +180,13 @@ async function poll() {
|
||||
pushState(lastXml);
|
||||
}
|
||||
// Load new diagram from server (before export, so we export latest).
|
||||
// While a page-targeted projection is on screen, skip the reload
|
||||
// so it doesn't fight the projection — and leave currentVersion
|
||||
// unadvanced so this bump is re-detected and applied once the
|
||||
// real document is restored.
|
||||
if ((forceReload || s.version > currentVersion) && s.xml && !projectionExportActive) {
|
||||
// While a page-targeted projection is on screen, only the restore
|
||||
// (forceReload) replaces it, so a new version doesn't fight the
|
||||
// projection; currentVersion stays unadvanced until then, so the
|
||||
// bump is applied with the real document.
|
||||
if ((forceReload || (s.version > currentVersion && !projectionExportActive)) && s.xml) {
|
||||
forceReload = false;
|
||||
projectionExportActive = false;
|
||||
currentVersion = s.version;
|
||||
loadDiagram(s.xml, true);
|
||||
}
|
||||
@@ -194,8 +198,11 @@ async function poll() {
|
||||
if (s.syncRequested && !pendingSyncExport && isReady && !projectionExportActive) {
|
||||
pendingSyncExport = true;
|
||||
pendingSyncBase = currentVersion;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||
setTimeout(() => { pendingSyncExport = false; }, 5000);
|
||||
// draw.io echoes the request in msg.message, so the reply can
|
||||
// be matched to this request
|
||||
const seq = ++syncExportSeq;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml', syncExport: seq }), '*');
|
||||
setTimeout(() => { if (seq === syncExportSeq) pendingSyncExport = false; }, 5000);
|
||||
}
|
||||
// Handle export request from MCP server (png/svg).
|
||||
//
|
||||
|
||||
@@ -413,7 +413,11 @@ function findTextBetweenTags(el: Element | null): string | null {
|
||||
if (node.nodeType === 1) {
|
||||
const text = findTextBetweenTags(node as Element)
|
||||
if (text) return text
|
||||
} else if (node.nodeType === 3 && !compressed) {
|
||||
} else if (
|
||||
// Text, or a CDATA section (draw.io reads it as text too)
|
||||
(node.nodeType === 3 || node.nodeType === 4) &&
|
||||
!compressed
|
||||
) {
|
||||
const text = node.textContent?.trim()
|
||||
if (text) return text.slice(0, 40)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ beforeAll(() => {
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
import { checkEditGate, contentFingerprint } from "../src/edit-gate.ts"
|
||||
import {
|
||||
checkEditGate,
|
||||
contentFingerprint,
|
||||
markPageSeen,
|
||||
} from "../src/edit-gate.ts"
|
||||
|
||||
const XML_A = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
@@ -130,3 +134,31 @@ describe("contentFingerprint", () => {
|
||||
expect(contentFingerprint("not xml at all")).toBe("not xml at all")
|
||||
})
|
||||
})
|
||||
|
||||
describe("markPageSeen", () => {
|
||||
const page = (id: string, label: string) =>
|
||||
`<diagram id="${id}" name="${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="c" value="${label}" vertex="1" parent="1"/></root></mxGraphModel></diagram>`
|
||||
const doc = (a: string, b: string) =>
|
||||
`<mxfile>${page("A", a)}${page("B", b)}</mxfile>`
|
||||
|
||||
it("counts the whole document as seen when the other pages are unchanged", () => {
|
||||
const seen = doc("a1", "b1")
|
||||
const live = doc("a2", "b1")
|
||||
expect(markPageSeen(seen, live, { page_id: "A" })).toBe(live)
|
||||
})
|
||||
|
||||
it("does not count a changed page the model was not shown", () => {
|
||||
// The user edited page B; the model looked at page A only
|
||||
const seen = doc("a1", "b1")
|
||||
const live = doc("a1", "b2")
|
||||
const marked = markPageSeen(seen, live, { page_id: "A" })
|
||||
expect(marked).toBe(seen)
|
||||
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
|
||||
const live = doc("a1", "b1")
|
||||
expect(markPageSeen("", live, { page_id: "A" })).toBe(live)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { addHistory, getHistory } from "../src/history.ts"
|
||||
import {
|
||||
getState,
|
||||
onSessionRecreate,
|
||||
requestExport,
|
||||
requestSync,
|
||||
setState,
|
||||
@@ -242,6 +243,27 @@ describe("POST /api/state", () => {
|
||||
expect(getHistory(id)).toHaveLength(before)
|
||||
expect(await waitForSync(id, 200)).toBe(true)
|
||||
})
|
||||
|
||||
it("ignores a sync reply older than a user edit saved meanwhile", async () => {
|
||||
const id = "mcp-late-sync"
|
||||
const version = setState(id, "<mxfile>A</mxfile>", undefined, true)
|
||||
requestSync(id)
|
||||
// The user's edit is saved before the sync reply arrives
|
||||
const edit = await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: "<mxfile>B</mxfile>",
|
||||
baseVersion: version,
|
||||
})
|
||||
expect(edit.status).toBe(200)
|
||||
const late = await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: "<mxfile>A</mxfile>",
|
||||
baseVersion: version,
|
||||
source: "sync",
|
||||
})
|
||||
expect(late.status).toBe(409)
|
||||
expect(getState(id)?.xml).toBe("<mxfile>B</mxfile>")
|
||||
})
|
||||
})
|
||||
|
||||
describe("export requests", () => {
|
||||
@@ -283,6 +305,19 @@ describe("export requests", () => {
|
||||
})
|
||||
|
||||
describe("preview page", () => {
|
||||
it("shows the saved diagram of a session whose state expired", async () => {
|
||||
const saved = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="kept" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
onSessionRecreate((id) => (id === "mcp-expired" ? saved : null))
|
||||
try {
|
||||
await request("/?mcp=mcp-expired")
|
||||
expect(getState("mcp-expired")?.xml).toBe(saved)
|
||||
await request("/?mcp=mcp-never-saved")
|
||||
expect(getState("mcp-never-saved")?.xml).not.toContain("kept")
|
||||
} finally {
|
||||
onSessionRecreate(() => null)
|
||||
}
|
||||
})
|
||||
|
||||
it("serves scripts that parse, with every placeholder filled", async () => {
|
||||
const res = await request("/?mcp=mcp-test-script")
|
||||
expect(res.body).not.toContain("{{")
|
||||
@@ -314,4 +349,24 @@ describe("history restore", () => {
|
||||
expect(res.status).toBe(200)
|
||||
expect(getState(id)?.xml).toBe("<mxfile>5</mxfile>")
|
||||
})
|
||||
|
||||
it("keeps manual edits in history before restoring", async () => {
|
||||
const id = "mcp-history-manual"
|
||||
const doc = (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>`
|
||||
const version = setState(id, doc("ai"))
|
||||
addHistory(id, doc("ai"))
|
||||
// An edit in the browser is not a history entry by itself
|
||||
const push = await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: doc("manual"),
|
||||
baseVersion: version,
|
||||
})
|
||||
expect(push.status).toBe(200)
|
||||
|
||||
const [entry] = getHistory(id)
|
||||
await postJson("/api/restore", { sessionId: id, id: entry.id })
|
||||
expect(getState(id)?.xml).toBe(doc("ai"))
|
||||
expect(getHistory(id).map((e) => e.xml)).toContain(doc("manual"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,19 +52,38 @@ describe("Autosaver", () => {
|
||||
it("keeps only the newest session files and never touches other files", () => {
|
||||
const dir = tempDir()
|
||||
const saver = new Autosaver(dir, 10, 2)
|
||||
for (const [i, id] of ["mine", "mcp-old", "mcp-mid"].entries()) {
|
||||
// Session ids look like mcp-<time in base 36>-<random>
|
||||
const ids = [
|
||||
"mine",
|
||||
"mcp-notes",
|
||||
"mcp-system-design-v2",
|
||||
"mcp-mgd0a1b2-old123",
|
||||
"mcp-mgd0a1b3-mid456",
|
||||
]
|
||||
for (const [i, id] of ids.entries()) {
|
||||
writeFileSync(join(dir, `${id}.drawio`), DIAGRAM)
|
||||
utimesSync(join(dir, `${id}.drawio`), 1000 + i, 1000 + i)
|
||||
}
|
||||
saver.schedule("mcp-new", DIAGRAM)
|
||||
saver.schedule("mcp-mgd0a1b4-new789", DIAGRAM)
|
||||
saver.flush()
|
||||
expect(readdirSync(dir).sort()).toEqual([
|
||||
"mcp-mid.drawio",
|
||||
"mcp-new.drawio",
|
||||
"mcp-mgd0a1b3-mid456.drawio",
|
||||
"mcp-mgd0a1b4-new789.drawio",
|
||||
"mcp-notes.drawio",
|
||||
"mcp-system-design-v2.drawio",
|
||||
"mine.drawio",
|
||||
])
|
||||
})
|
||||
|
||||
it("reads a session's saved diagram back", () => {
|
||||
const saver = new Autosaver(tempDir(), 10)
|
||||
expect(saver.load("mcp-none")).toBeNull()
|
||||
saver.schedule("mcp-back", DIAGRAM)
|
||||
saver.flush()
|
||||
expect(saver.load("mcp-back")).toBe(DIAGRAM)
|
||||
expect(new Autosaver(null).load("mcp-back")).toBeNull()
|
||||
})
|
||||
|
||||
it("does nothing when saving is off", () => {
|
||||
const saver = new Autosaver(null)
|
||||
expect(saver.pathFor("mcp-x")).toBeNull()
|
||||
|
||||
@@ -10,7 +10,7 @@ beforeAll(() => {
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
import { prepareNewDiagram } from "../src/new-diagram.ts"
|
||||
import { prepareNewDiagram, reservedIdError } from "../src/new-diagram.ts"
|
||||
import { hasCells, wrapCellsInModel } from "../src/pages.ts"
|
||||
import { validateAndFixXml } from "../src/xml-validation.ts"
|
||||
|
||||
@@ -59,6 +59,15 @@ describe("wrapCellsInModel", () => {
|
||||
expect(wrapCellsInModel(file)).toBe(file)
|
||||
})
|
||||
|
||||
it("replaces root cells written over two lines", () => {
|
||||
const wrapped = wrapCellsInModel(
|
||||
`<mxCell id="0">\n</mxCell>\n<mxCell id="1" parent="0">\n</mxCell>\n${A}`,
|
||||
)
|
||||
expect(wrapped).toBe(
|
||||
`<mxGraphModel><root>${ROOTS}${A}</root></mxGraphModel>`,
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps a UserObject cell at the end", () => {
|
||||
const wrapped = `<UserObject id="u" label="U"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>`
|
||||
expect(wrapCellsInModel(A + wrapped)).toContain(wrapped)
|
||||
@@ -75,6 +84,35 @@ describe("hasCells", () => {
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("counts cells with spaces around the =", () => {
|
||||
expect(hasCells(`<mxCell id = "a" vertex="1" parent="1"/>`)).toBe(true)
|
||||
expect(
|
||||
hasCells(`<mxCell id = "0"/><mxCell id = "1" parent="0"/>`),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("reservedIdError", () => {
|
||||
it("finds a shape that uses a root cell id", () => {
|
||||
expect(reservedIdError(A)).toBeNull()
|
||||
expect(reservedIdError(ROOTS + A)).toBeNull()
|
||||
expect(
|
||||
reservedIdError(`<mxCell id = "1" vertex="1" parent="1"/>`),
|
||||
).toMatch(/"0" and "1"/)
|
||||
// A linked or labelled shape has its id on the wrapper
|
||||
expect(
|
||||
reservedIdError(
|
||||
`<UserObject id="1" label="Docs"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>`,
|
||||
),
|
||||
).toMatch(/"0" and "1"/)
|
||||
// A whole model has its own root cells
|
||||
expect(
|
||||
reservedIdError(
|
||||
`<mxGraphModel><root>${ROOTS}</root></mxGraphModel>`,
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("prepareNewDiagram", () => {
|
||||
|
||||
@@ -317,4 +317,12 @@ describe("text directly under a page", () => {
|
||||
expect(r.valid).toBe(true)
|
||||
expect(r.fixed).not.toContain("\\n")
|
||||
})
|
||||
|
||||
it("rejects CDATA text before the model of a page", () => {
|
||||
const r = validateAndFixXml(
|
||||
`<mxfile><diagram id="p" name="P"><![CDATA[not-base64!]]>${model(`<mxCell id="2" vertex="1" parent="1"/>`)}</diagram></mxfile>`,
|
||||
)
|
||||
expect(r.valid).toBe(false)
|
||||
expect(r.error).toMatch(/not-base64/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user