Files
next-ai-draw-io/packages/mcp-server/tests/history.test.ts
T
Dayuan Jiang 77be43d996 feat(mcp-server): saved sessions, restore_version and History on disk (#981)
* feat(mcp-server): list and resume saved diagram sessions

start_session accepts an optional session_id to continue a saved diagram
under the same preview URL and auto-save file, reporting the restored
pages. New read-only tool list_saved_diagrams prints every auto-saved
file newest first with its pages. Autosaver gains list() and dataDir().
INSTRUCTIONS and both READMEs mention the new tool.

* feat(mcp-server): add restore_version tool for undo and redo from History

Add the restore_version write tool so the model can put an earlier
version from History back on the canvas (steps_back, default 1). The
current canvas is kept in History unless it is the blank page, so a redo
is possible. http-server.ts exports restoreHistoryEntry, shared by the
HTTP restore endpoint and the tool; history.ts exports HistoryEntry and
otherVersions (distinct versions other than the canvas, newest first).
The drawing guide tells the model to call restore_version when the user
asks to undo, and both READMEs document the tool.

* feat(mcp-server): save the diagram History next to the auto-saved file

The Autosaver writes each session's History (the XML of its last 20 versions, without thumbnails) to <session-id>.history.json beside the .drawio file, after the same 1 second delay and only when the entries changed. When a saved session is resumed after an idle timeout or a process restart, the saved History is loaded back before the diagram, so the History panel and restore_version keep working. Removing the oldest files also removes their History files.

* fix(mcp-server): review fixes for saved sessions, undo tool, history on disk

* fix(mcp-server): Codex review fixes for saved sessions, undo and History on disk

- start_session resumes every saved file, also a cleared one or one of
  empty named pages, and decompresses pages draw.io compressed
- History kept by a lost user edit or a recovering tab is saved too
- A diagram cleared before its first save keeps its History on disk
- The History file is written again after the 50-file cap removed it
- restore_version compares versions like the edit gate (a bare
  mxGraphModel from the browser has no page name), checks the restore
  result, and says "none" when no version is left
- The preview closes an open History list when the server state was
  recreated, since the entries got new ids
- start_session's description mentions session_id

* fix(mcp-server): compare History versions pairwise and tie restores to the server state

Second Codex review of the History fixes:
- One bare mxGraphModel in History no longer hides page renames in every
  comparison: names are left out only when one of the two is bare
- A History list asked for before the server recreated the session is
  dropped, and a restore names the state its list belongs to; the server
  refuses one from a lost state (History ids change after a restart)

* fix(mcp-server): restore from a History list with the state it was taken in

Third Codex review: a restore sent the tab's current state, so an old
list shown after a failed refresh passed the server's check. The list now
keeps the state it came from, and the server refuses a missing (null)
state too; only a tab of an older version, which sends none, is let
through.
2026-10-11 20:23:23 +09:00

119 lines
3.8 KiB
TypeScript

/**
* Tests for the diagram history: the versions restore_version can go to.
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
import {
addHistory,
clearHistory,
getHistory,
isInHistory,
otherVersions,
} from "../src/history.ts"
beforeAll(() => {
// XML parsing for the content fingerprints
installDomPolyfill()
})
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>`
const A = page("a")
const B = page("b")
const C = page("c")
// draw.io's copy of B: other attribute order, whitespace and viewport
// attributes, same diagram
const B_COPY = `<mxfile host="app.diagrams.net">
<diagram name="P" id="p">
<mxGraphModel dx="100" dy="200" pageWidth="850">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="b" parent="1" vertex="1" />
</root>
</mxGraphModel>
</diagram>
</mxfile>`
const fill = (id: string, versions: string[]) => {
clearHistory(id)
for (const xml of versions) addHistory(id, xml)
}
describe("otherVersions", () => {
it("lists the versions before the canvas, newest first", () => {
const id = "history-abc"
fill(id, [A, B, C])
expect(otherVersions(id, C).map((e) => e.xml)).toEqual([B, A])
})
it("lists each version once after an undo, the left one first", () => {
const id = "history-abcb"
fill(id, [A, B, C, B])
expect(otherVersions(id, B).map((e) => e.xml)).toEqual([C, A])
})
it("lists every version when the canvas is in none of them", () => {
const id = "history-manual"
fill(id, [A, B, C])
expect(otherVersions(id, page("manual")).map((e) => e.xml)).toEqual([
C,
B,
A,
])
})
it("counts a re-serialised copy as the same version", () => {
const id = "history-copy"
fill(id, [A, B, B_COPY])
expect(otherVersions(id, B).map((e) => e.xml)).toEqual([A])
expect(otherVersions(id, B_COPY).map((e) => e.xml)).toEqual([A])
})
it("keeps the newest copy of a repeated version", () => {
const id = "history-newest"
fill(id, [A, B, A])
const [newestA] = otherVersions(id, B)
expect(newestA.id).toBe(getHistory(id)[2].id)
expect(otherVersions(id, B)).toHaveLength(1)
})
it("matches a bare model from the browser with its named page", () => {
const id = "history-bare"
const named = (xml: string) => xml.replace('name="P"', 'name="Arch"')
fill(id, [named(A), named(B)])
// The browser's sync sent page B without its <mxfile> wrapper
const bareB = B.replace(/^.*?(<mxGraphModel>)/, "$1").replace(
"</diagram></mxfile>",
"",
)
expect(otherVersions(id, bareB).map((e) => e.xml)).toEqual([named(A)])
expect(isInHistory(id, bareB)).toBe(true)
expect(isInHistory(id, C)).toBe(false)
})
it("still counts a rename when an unrelated bare model is in History", () => {
const id = "history-mixed"
const bareX = page("x")
.replace(/^.*?(<mxGraphModel>)/, "$1")
.replace("</diagram></mxfile>", "")
const arch = B.replace('name="P"', 'name="Arch"')
const overview = B.replace('name="P"', 'name="Overview"')
fill(id, [bareX, arch])
expect(otherVersions(id, overview).map((e) => e.xml)).toEqual([
arch,
bareX,
])
expect(isInHistory(id, overview)).toBe(false)
})
it("returns nothing for an empty history", () => {
const id = "history-empty"
clearHistory(id)
expect(otherVersions(id, A)).toEqual([])
})
})