mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-07 02:07:47 +08:00
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:
@@ -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))
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="1" vertex="1"" vertex="1" parent="1"/>`,
|
||||
),
|
||||
).toBeNull()
|
||||
// A whole model has its own root cells
|
||||
expect(
|
||||
reservedIdError(
|
||||
|
||||
Reference in New Issue
Block a user